From 8e79bd9e6c431b281c16ebcd0956877dcfe0f301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 30 Aug 2026 22:04:09 +0900 Subject: [PATCH 001/125] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=A0=80=EC=9E=A5=C2=B7=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=C2=B7=ED=94=84=EB=A6=AC=EC=85=8B=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/permissions/dmnote-allow-all.json | 1 + src-tauri/src/app.rs | 1 + src-tauri/src/commands/editor/history.rs | 1 + src-tauri/src/commands/editor/state.rs | 6 + src-tauri/src/commands/keys/keys.rs | 39 ++ src-tauri/src/commands/layout/mod.rs | 1 + src-tauri/src/commands/layout/sprite_items.rs | 8 + src-tauri/src/commands/preset/load.rs | 187 +++++- src-tauri/src/commands/preset/mod.rs | 6 +- src-tauri/src/commands/preset/save.rs | 57 +- src-tauri/src/models/editor.rs | 37 +- src-tauri/src/models/mod.rs | 188 ++++++ src-tauri/src/services/obs_bridge.rs | 8 +- src-tauri/src/services/preview_broker.rs | 117 +++- src-tauri/src/state/app_state.rs | 1 + src-tauri/src/state/editor.rs | 358 +++++++++- src-tauri/src/state/editor_ops.rs | 628 ++++++++++++++++-- src-tauri/src/state/history.rs | 3 + src-tauri/src/state/migration.rs | 112 +++- src-tauri/src/state/native_element_id.rs | 507 ++++++++++++-- src-tauri/src/state/store.rs | 145 +++- 22 files changed, 2229 insertions(+), 184 deletions(-) create mode 100644 src-tauri/src/commands/layout/sprite_items.rs diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index a6f82f764..a6181e9db 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","commit_gesture","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_fetch_import","css_get","css_get_use","css_history_activate","css_history_get","css_history_remove","css_load","css_reset","css_set_content","css_tab_activate_history","css_tab_clear","css_tab_export","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","editor_commit","editor_get","editor_preview_cancel","editor_preview_publish","editor_preview_subscribe","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","history_redo","history_status","history_undo","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","knob_positions_get","knob_positions_update","layer_groups_get","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_hit_renderer_ready","overlay_reset_position","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","overlay_sync_hit_regions","overlay_transition_fade","panel_drag_disarm_dock_zone","panel_drag_hit_test","panel_drag_present_and_start","panel_drag_start_existing","panel_window_apply_native_chrome","panel_window_arm_open","panel_window_close_ack","panel_window_dock","panel_window_drag_context","panel_window_move_to","panel_window_present","panel_window_present_at","panel_window_reset_position","panel_window_set_drag_cursor","panel_window_take_restore_request","plugin_authority_reset","plugin_bridge_send","plugin_bridge_send_to","plugin_group_refs_get","plugin_instances_commit","plugin_instances_get","plugin_instances_reconcile","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","commit_gesture","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_fetch_import","css_get","css_get_use","css_history_activate","css_history_get","css_history_remove","css_load","css_reset","css_set_content","css_tab_activate_history","css_tab_clear","css_tab_export","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","editor_commit","editor_get","editor_preview_cancel","editor_preview_publish","editor_preview_subscribe","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","history_redo","history_status","history_undo","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","knob_positions_get","knob_positions_update","layer_groups_get","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_hit_renderer_ready","overlay_reset_position","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","overlay_sync_hit_regions","overlay_transition_fade","panel_drag_disarm_dock_zone","panel_drag_hit_test","panel_drag_present_and_start","panel_drag_start_existing","panel_window_apply_native_chrome","panel_window_arm_open","panel_window_close_ack","panel_window_dock","panel_window_drag_context","panel_window_move_to","panel_window_present","panel_window_present_at","panel_window_reset_position","panel_window_set_drag_cursor","panel_window_take_restore_request","plugin_authority_reset","plugin_bridge_send","plugin_bridge_send_to","plugin_group_refs_get","plugin_instances_commit","plugin_instances_get","plugin_instances_reconcile","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","sprite_positions_get","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index 6416db88d..95b15aa18 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -148,6 +148,7 @@ "sound_set_enabled", "sound_set_hidden", "sound_update_processed_wav", + "sprite_positions_get", "stat_positions_get", "stat_positions_update", "window_close", diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index c59f11e5e..a1f4d0747 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -315,6 +315,7 @@ pub fn run() { commands::layout::graph_items::graph_positions_update, commands::layout::knob_items::knob_positions_get, commands::layout::knob_items::knob_positions_update, + commands::layout::sprite_items::sprite_positions_get, commands::layout::font::font_load, commands::layout::overlay::overlay_get, commands::layout::overlay::overlay_set_visible, diff --git a/src-tauri/src/commands/editor/history.rs b/src-tauri/src/commands/editor/history.rs index e3e0ac886..6816184c1 100644 --- a/src-tauri/src/commands/editor/history.rs +++ b/src-tauri/src/commands/editor/history.rs @@ -405,6 +405,7 @@ fn preset_history_event_projection( stat_positions: snapshot.document.stat_positions.clone(), graph_positions: snapshot.document.graph_positions.clone(), knob_positions: snapshot.document.knob_positions.clone(), + sprite_positions: snapshot.document.sprite_positions.clone(), custom_tabs: snapshot.custom_tabs.clone(), selected_key_type: snapshot.selected_key_type.clone(), tab_note_overrides: snapshot.settings.tab_note_overrides.clone(), diff --git a/src-tauri/src/commands/editor/state.rs b/src-tauri/src/commands/editor/state.rs index 8342e85d1..4ec53207f 100644 --- a/src-tauri/src/commands/editor/state.rs +++ b/src-tauri/src/commands/editor/state.rs @@ -26,6 +26,7 @@ fn legacy_event_name(field: EditorField) -> &'static str { EditorField::StatPositions => "statPositions:changed", EditorField::GraphPositions => "graphPositions:changed", EditorField::KnobPositions => "knobPositions:changed", + EditorField::SpritePositions => "spritePositions:changed", EditorField::LayerGroups => "layerGroups:changed", } } @@ -77,6 +78,9 @@ pub(crate) fn publish_legacy_editor_fields( EditorField::KnobPositions => { emit_best_effort(app, event, &change.document.knob_positions); } + EditorField::SpritePositions => { + emit_best_effort(app, event, &change.document.sprite_positions); + } EditorField::LayerGroups => { emit_best_effort(app, event, &change.document.layer_groups); } @@ -300,6 +304,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ]; assert_eq!(&fields, projected_legacy_fields(true, &fields)); @@ -311,6 +316,7 @@ mod tests { "statPositions:changed", "graphPositions:changed", "knobPositions:changed", + "spritePositions:changed", "layerGroups:changed", ] ); diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 01ea3fdf0..86ee78587 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -122,6 +122,7 @@ fn reset_all_editor_data( store.stat_positions = stat_positions.clone(); store.graph_positions.clear(); store.knob_positions.clear(); + store.sprite_positions.clear(); store.layer_groups.clear(); store.key_counters = zeroed_counters(keys); store.custom_tabs.clear(); @@ -217,6 +218,7 @@ fn apply_reset_mode_data(store: &mut AppStoreData, mode: &str, kind: ModeResetKi store.graph_positions.insert(mode.to_string(), Vec::new()); store.knob_positions.insert(mode.to_string(), Vec::new()); + store.sprite_positions.insert(mode.to_string(), Vec::new()); store.layer_groups.remove(mode); store.tab_css_overrides.remove(mode); store.tab_note_overrides.remove(mode); @@ -268,6 +270,19 @@ fn mode_positions_equal_without_ids( for position in current_knobs.iter_mut().flatten() { position.position.id.clear(); } + + let mut current_sprites = current.sprite_positions.get(mode).cloned(); + let mut candidate_sprites = candidate.sprite_positions.get(mode).cloned(); + for sprite in current_sprites + .iter_mut() + .chain(candidate_sprites.iter_mut()) + .flatten() + { + sprite.id.clear(); + for pose in &mut sprite.poses { + pose.pose_id.clear(); + } + } for position in candidate_knobs.iter_mut().flatten() { position.position.id.clear(); } @@ -276,6 +291,7 @@ fn mode_positions_equal_without_ids( && optional_collections_equal(current_stats.as_deref(), candidate_stats.as_deref()) && optional_collections_equal(current_graphs.as_deref(), candidate_graphs.as_deref()) && optional_collections_equal(current_knobs.as_deref(), candidate_knobs.as_deref()) + && optional_collections_equal(current_sprites.as_deref(), candidate_sprites.as_deref()) } fn optional_collections_equal( @@ -378,6 +394,7 @@ fn delete_custom_tab_data(store: &mut AppStoreData, id: &str, plan: &CustomTabDe store.stat_positions.remove(id); store.graph_positions.remove(id); store.knob_positions.remove(id); + store.sprite_positions.remove(id); store.layer_groups.remove(id); store.tab_css_overrides.remove(id); store.tab_note_overrides.remove(id); @@ -557,6 +574,7 @@ fn keys_reset_all_inner( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, @@ -590,6 +608,7 @@ fn keys_reset_all_inner( let stat_positions = transaction.change.document.stat_positions; let graph_positions = transaction.change.document.graph_positions; let knob_positions = transaction.change.document.knob_positions; + let sprite_positions = transaction.change.document.sprite_positions; let layer_groups = transaction.change.document.layer_groups; let selected_key_type = transaction.change.selected_key_type; let custom_tabs: Vec = Vec::new(); @@ -604,6 +623,7 @@ fn keys_reset_all_inner( emit_best_effort(app, "statPositions:changed", &stat_positions); emit_best_effort(app, "graphPositions:changed", &graph_positions); emit_best_effort(app, "knobPositions:changed", &knob_positions); + emit_best_effort(app, "spritePositions:changed", &sprite_positions); emit_best_effort(app, "layerGroups:changed", &layer_groups); emit_best_effort( app, @@ -672,6 +692,7 @@ fn keys_reset_mode_inner( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(mode.clone()), @@ -719,6 +740,11 @@ fn keys_reset_mode_inner( "knobPositions:changed", &transaction.change.document.knob_positions, ); + emit_best_effort( + app, + "spritePositions:changed", + &transaction.change.document.sprite_positions, + ); emit_best_effort( app, "layerGroups:changed", @@ -898,6 +924,7 @@ fn custom_tabs_delete_inner( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], plugin_instances_reset: PluginInstancesResetScope::Mode(id.clone()), @@ -964,6 +991,11 @@ fn custom_tabs_delete_inner( "knobPositions:changed", &transaction.change.document.knob_positions, ); + emit_best_effort( + app, + "spritePositions:changed", + &transaction.change.document.sprite_positions, + ); emit_best_effort( app, "layerGroups:changed", @@ -1723,6 +1755,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(reset_mode.to_string()), @@ -1790,6 +1823,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(mode.to_string()), @@ -1855,6 +1889,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(reset_mode.to_string()), @@ -1926,6 +1961,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode(mode.to_string()), @@ -1998,6 +2034,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, @@ -2060,6 +2097,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, @@ -2136,6 +2174,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::All, diff --git a/src-tauri/src/commands/layout/mod.rs b/src-tauri/src/commands/layout/mod.rs index 15130b171..36ec495f5 100644 --- a/src-tauri/src/commands/layout/mod.rs +++ b/src-tauri/src/commands/layout/mod.rs @@ -5,4 +5,5 @@ pub mod overlay; pub mod panel; mod panel_drag_cursor; pub mod settings; +pub mod sprite_items; pub mod stat_items; diff --git a/src-tauri/src/commands/layout/sprite_items.rs b/src-tauri/src/commands/layout/sprite_items.rs new file mode 100644 index 000000000..790ad0c50 --- /dev/null +++ b/src-tauri/src/commands/layout/sprite_items.rs @@ -0,0 +1,8 @@ +use tauri::AppHandle; + +use crate::{commands::run_blocking, errors::CmdResult, models::SpritePositions}; + +#[tauri::command] +pub async fn sprite_positions_get(app: AppHandle) -> CmdResult { + run_blocking(app, |_, state| Ok(state.store.snapshot().sprite_positions)).await +} diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 3d442d414..4e74b8645 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -23,9 +23,9 @@ use crate::{ scrub_removed_text_outline_fields, AppStoreData, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, EditorCommitOrigin, EditorField, FontSettings, FontType, GradientSpec, GraphPositions, KeyMappings, KeyPosition, KeyPositions, KeySlot, KnobPositions, - LayerGroups, NoteSettings, NoteSettingsPatch, SettingsPatchInput, StatPositions, TabCss, - TabCssOverrides, TabNoteSettings, POSITION_COLLECTION_FIELDS, SHADOW_BLUR_MAX, - SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + LayerGroups, NoteSettings, NoteSettingsPatch, SettingsPatchInput, SpritePositions, + StatPositions, TabCss, TabCssOverrides, TabNoteSettings, POSITION_COLLECTION_FIELDS, + SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, }, services::settings::apply_patch_to_store, state::{ @@ -378,6 +378,7 @@ fn preset_load_from_path( let mut stat_positions = preset.stat_positions.unwrap_or_default(); let mut graph_positions = preset.graph_positions.unwrap_or_default(); let mut knob_positions = preset.knob_positions.unwrap_or_default(); + let mut sprite_positions = preset.sprite_positions.unwrap_or_default(); migrate_imported_font_weights( &mut positions, &mut stat_positions, @@ -444,6 +445,7 @@ fn preset_load_from_path( &mut stat_positions, &mut graph_positions, &mut knob_positions, + &mut sprite_positions, preset.embedded_local_images.as_deref(), )?; restore_preset_local_sounds( @@ -496,6 +498,7 @@ fn preset_load_from_path( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], admission, @@ -511,6 +514,7 @@ fn preset_load_from_path( store.stat_positions = stat_positions; store.graph_positions = graph_positions; store.knob_positions = knob_positions; + store.sprite_positions = sprite_positions; store.custom_tabs = custom_tabs; store.selected_key_type = selected_key_type; store.tab_note_overrides = tab_note_overrides; @@ -558,6 +562,7 @@ fn preset_load_from_path( let stat_positions = transaction.change.document.stat_positions; let graph_positions = transaction.change.document.graph_positions; let knob_positions = transaction.change.document.knob_positions; + let sprite_positions = transaction.change.document.sprite_positions; let layer_groups = transaction.change.document.layer_groups; if let Err(error) = state.emit_settings_changed(&diff, app) { @@ -574,6 +579,7 @@ fn preset_load_from_path( stat_positions, graph_positions, knob_positions, + sprite_positions, custom_tabs, selected_key_type, tab_note_overrides, @@ -634,6 +640,7 @@ fn preset_load_tab_from_path( stat_positions, graph_positions, knob_positions, + sprite_positions, selected_key_type, tab_note_overrides, layer_groups, @@ -685,6 +692,12 @@ fn preset_load_tab_from_path( src_knob_positions.insert(current_tab_id.clone(), v.clone()); } + let imported_sprite_positions = sprite_positions.unwrap_or_default(); + let mut src_sprite_positions: SpritePositions = HashMap::new(); + if let Some(v) = imported_sprite_positions.get(&source_tab_id) { + src_sprite_positions.insert(current_tab_id.clone(), v.clone()); + } + migrate_imported_font_weights( &mut src_key_positions, &mut src_stat_positions, @@ -705,6 +718,7 @@ fn preset_load_tab_from_path( &mut src_stat_positions, &mut src_graph_positions, &mut src_knob_positions, + &mut src_sprite_positions, embedded_local_images.as_deref(), )?; restore_preset_local_sounds( @@ -720,6 +734,7 @@ fn preset_load_tab_from_path( let imported_stat_positions = src_stat_positions.remove(¤t_tab_id); let imported_graph_positions = src_graph_positions.remove(¤t_tab_id); let imported_knob_positions = src_knob_positions.remove(¤t_tab_id); + let imported_sprite_positions = src_sprite_positions.remove(¤t_tab_id); let imported_override = imported_tab_note_overrides.get(&source_tab_id).cloned(); let imported_groups = layer_groups.map(|groups| groups.get(&source_tab_id).cloned().unwrap_or_default()); @@ -762,6 +777,7 @@ fn preset_load_tab_from_path( EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], admission, @@ -771,6 +787,7 @@ fn preset_load_tab_from_path( let stat_positions_written = imported_stat_positions.is_some(); let graph_positions_written = imported_graph_positions.is_some(); let knob_positions_written = imported_knob_positions.is_some(); + let sprite_positions_written = imported_sprite_positions.is_some(); merge_tab_preset_key_pair(store, ¤t_tab_id, src_keys, imported_key_positions); if let Some(positions) = imported_stat_positions { store @@ -787,6 +804,11 @@ fn preset_load_tab_from_path( .knob_positions .insert(current_tab_id.clone(), positions); } + if let Some(positions) = imported_sprite_positions { + store + .sprite_positions + .insert(current_tab_id.clone(), positions); + } rekey_tab_preset_elements( store, ¤t_tab_id, @@ -794,6 +816,7 @@ fn preset_load_tab_from_path( stat_positions_written, graph_positions_written, knob_positions_written, + sprite_positions_written, ); apply_tab_note_override( store, @@ -858,6 +881,7 @@ fn preset_load_tab_from_path( let full_stat_positions = transaction.change.document.stat_positions; let full_graph_positions = transaction.change.document.graph_positions; let full_knob_positions = transaction.change.document.knob_positions; + let full_sprite_positions = transaction.change.document.sprite_positions; let full_layer_groups = transaction.change.document.layer_groups; if let Some(diff) = settings_diff.as_ref() { @@ -872,6 +896,7 @@ fn preset_load_tab_from_path( emit_best_effort(app, "statPositions:changed", &full_stat_positions); emit_best_effort(app, "graphPositions:changed", &full_graph_positions); emit_best_effort(app, "knobPositions:changed", &full_knob_positions); + emit_best_effort(app, "spritePositions:changed", &full_sprite_positions); emit_best_effort(app, "tabNote:changed_all", &full_tab_note_overrides); // OBS 브릿지: 탭 프리셋 로드 시 전체 스냅샷 재전송 @@ -1082,6 +1107,7 @@ fn rekey_tab_preset_elements( stat_positions_written: bool, graph_positions_written: bool, knob_positions_written: bool, + sprite_positions_written: bool, ) { crate::state::native_element_id::rekey_mode_element_ids_for_collections( store, @@ -1090,6 +1116,7 @@ fn rekey_tab_preset_elements( stat_positions_written, graph_positions_written, knob_positions_written, + sprite_positions_written, ); // 프리셋이 위치를 주지 않은 컬렉션은 기존 요소가 값 그대로 남는다 - // 신원을 회전시키지 않고 정렬이 덧붙인 빈 항목만 채운다 @@ -1100,6 +1127,7 @@ fn rekey_tab_preset_elements( !stat_positions_written, !graph_positions_written, !knob_positions_written, + !sprite_positions_written, ); } @@ -1325,6 +1353,28 @@ fn restore_preset_local_images( stat_positions: &mut StatPositions, graph_positions: &mut GraphPositions, knob_positions: &mut KnobPositions, + sprite_positions: &mut SpritePositions, + embedded_local_images: Option<&[EmbeddedLocalImage]>, +) -> CmdResult<()> { + let app_data_dir = app.path().app_data_dir()?; + restore_preset_local_images_in_dir( + &app_data_dir.join("images"), + key_positions, + stat_positions, + graph_positions, + knob_positions, + sprite_positions, + embedded_local_images, + ) +} + +fn restore_preset_local_images_in_dir( + images_dir: &Path, + key_positions: &mut KeyPositions, + stat_positions: &mut StatPositions, + graph_positions: &mut GraphPositions, + knob_positions: &mut KnobPositions, + sprite_positions: &mut SpritePositions, embedded_local_images: Option<&[EmbeddedLocalImage]>, ) -> CmdResult<()> { let has_any_images = key_positions.values().any(|positions| { @@ -1347,6 +1397,14 @@ fn restore_preset_local_images( option_has_non_empty_text(&knob_position.position.active_image) || option_has_non_empty_text(&knob_position.position.inactive_image) }) + }) || sprite_positions.values().any(|sprites| { + sprites.iter().any(|sprite| { + option_has_non_empty_text(&sprite.base_image) + || sprite + .poses + .iter() + .any(|pose| option_has_non_empty_text(&pose.image_override)) + }) }); if !has_any_images { @@ -1360,20 +1418,18 @@ fn restore_preset_local_images( .collect(); let mut restored_path_cache: HashMap = HashMap::new(); - let app_data_dir = app.path().app_data_dir()?; - let images_dir = app_data_dir.join("images"); - fs::create_dir_all(&images_dir)?; + fs::create_dir_all(images_dir)?; for positions in key_positions.values_mut() { for position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut position.inactive_image, @@ -1384,13 +1440,13 @@ fn restore_preset_local_images( for positions in stat_positions.values_mut() { for stat_position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut stat_position.position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut stat_position.position.inactive_image, @@ -1401,13 +1457,13 @@ fn restore_preset_local_images( for positions in graph_positions.values_mut() { for graph_position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut graph_position.position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut graph_position.position.inactive_image, @@ -1418,13 +1474,13 @@ fn restore_preset_local_images( for positions in knob_positions.values_mut() { for knob_position in positions.iter_mut() { restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut knob_position.position.active_image, )?; restore_position_image_reference( - &images_dir, + images_dir, &embedded_map, &mut restored_path_cache, &mut knob_position.position.inactive_image, @@ -1432,6 +1488,25 @@ fn restore_preset_local_images( } } + for sprites in sprite_positions.values_mut() { + for sprite in sprites { + restore_position_image_reference( + images_dir, + &embedded_map, + &mut restored_path_cache, + &mut sprite.base_image, + )?; + for pose in &mut sprite.poses { + restore_position_image_reference( + images_dir, + &embedded_map, + &mut restored_path_cache, + &mut pose.image_override, + )?; + } + } + } + Ok(()) } @@ -1778,7 +1853,8 @@ mod tests { defaults::{default_keys, default_positions}, models::{ CustomCssHistoryEntry, CustomFont, FontWeightRange, GraphPosition, GraphStatType, - GraphType, JsPlugin, KnobPosition, StatPosition, StatType, + GraphType, JsPlugin, KnobPosition, ReactiveSpritePosition, SpritePose, StatPosition, + StatType, }, }; @@ -1816,6 +1892,81 @@ mod tests { assert_eq!(store.custom_css_history, history); } + #[test] + fn reactive_sprite_and_images_survive_preset_export_import_round_trip() { + let root = std::env::temp_dir().join(format!( + "dmnote-reactive-sprite-preset-round-trip-{}", + uuid::Uuid::new_v4() + )); + let source_dir = root.join("source"); + let restored_dir = root.join("restored"); + std::fs::create_dir_all(&source_dir).unwrap(); + let base_image = source_dir.join("base.png"); + let pose_image = source_dir.join("pose.png"); + std::fs::write(&base_image, b"base-image").unwrap(); + std::fs::write(&pose_image, b"pose-image").unwrap(); + + let sprite_id = uuid::Uuid::new_v4().to_string(); + let pose_id = uuid::Uuid::new_v4().to_string(); + let trigger_id = uuid::Uuid::new_v4().to_string(); + let sprites = SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: sprite_id.clone(), + base_image: Some(base_image.to_string_lossy().into_owned()), + poses: vec![SpritePose { + pose_id: pose_id.clone(), + triggers: vec![trigger_id.clone()], + image_override: Some(pose_image.to_string_lossy().into_owned()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + )]); + + let (_, _, _, _, exported_sprites, embedded) = + crate::commands::preset::save::build_preset_image_payload( + &KeyPositions::new(), + &StatPositions::new(), + &GraphPositions::new(), + &KnobPositions::new(), + &sprites, + ) + .unwrap(); + assert_eq!(embedded.len(), 2); + + let preset_bytes = serde_json::to_vec(&PresetFile { + sprite_positions: Some(exported_sprites), + embedded_local_images: Some(embedded), + ..PresetFile::default() + }) + .unwrap(); + let imported: PresetFile = serde_json::from_slice(&preset_bytes).unwrap(); + let mut imported_sprites = imported.sprite_positions.unwrap(); + + restore_preset_local_images_in_dir( + &restored_dir, + &mut KeyPositions::new(), + &mut StatPositions::new(), + &mut GraphPositions::new(), + &mut KnobPositions::new(), + &mut imported_sprites, + imported.embedded_local_images.as_deref(), + ) + .unwrap(); + + let restored = &imported_sprites["4key"][0]; + assert_eq!(restored.id, sprite_id); + assert_eq!(restored.poses[0].pose_id, pose_id); + assert_eq!(restored.poses[0].triggers, [trigger_id]); + let restored_base = Path::new(restored.base_image.as_deref().unwrap()); + let restored_pose = Path::new(restored.poses[0].image_override.as_deref().unwrap()); + assert_eq!(std::fs::read(restored_base).unwrap(), b"base-image"); + assert_eq!(std::fs::read(restored_pose).unwrap(), b"pose-image"); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn committed_preset_css_paths_exclude_unrelated_store_paths() { let mut committed = AppStoreData { @@ -2897,9 +3048,9 @@ mod tests { let untouched_id = store.key_positions["untouched"][0].id.clone(); let original_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, true, false); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false, false); let first_ids = target_preset_ids(&store); - rekey_tab_preset_elements(&mut store, "target", true, true, true, false); + rekey_tab_preset_elements(&mut store, "target", true, true, true, false, false); let second_ids = target_preset_ids(&store); assert!(original_ids[..3] @@ -2932,7 +3083,7 @@ mod tests { .unwrap() .push(KeyPosition::default()); - rekey_tab_preset_elements(&mut store, "target", false, false, false, false); + rekey_tab_preset_elements(&mut store, "target", false, false, false, false, false); // 값이 그대로인 기존 키는 신원을 지키고, 덧붙은 슬롯만 새 id를 받는다 assert_eq!(store.key_positions["target"][0].id, original_ids[0]); diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index 41de430e1..f756e7087 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::models::{ CustomCss, CustomJs, CustomTab, FontSettings, GraphPositions, KeyMappings, KeyPositions, - KnobPositions, LayerGroups, NoteSettings, StatPositions, TabCssOverrides, TabNoteOverrides, + KnobPositions, LayerGroups, NoteSettings, SpritePositions, StatPositions, TabCssOverrides, + TabNoteOverrides, }; use crate::state::local_asset_path::{file_url_to_path, FileUrlPath}; @@ -28,6 +29,7 @@ pub struct PresetSnapshot { pub stat_positions: StatPositions, pub graph_positions: GraphPositions, pub knob_positions: KnobPositions, + pub sprite_positions: SpritePositions, pub custom_tabs: Vec, pub selected_key_type: String, pub tab_note_overrides: TabNoteOverrides, @@ -42,6 +44,8 @@ pub(crate) struct PresetFile { pub graph_positions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub knob_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sprite_positions: Option, pub background_color: Option, pub note_settings: Option, pub note_effect: Option, diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index 75e8aa169..8720f93b0 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -12,7 +12,7 @@ use crate::{ errors::{CmdResult, CommandError}, models::{ FontSettings, FontType, GraphPositions, KeyMappings, KeyPositions, KnobPositions, - LayerGroups, StatPositions, TabCssOverrides, TabNoteOverrides, + LayerGroups, SpritePositions, StatPositions, TabCssOverrides, TabNoteOverrides, }, state::{atomic_file::atomic_replace, AppState}, }; @@ -58,13 +58,20 @@ fn preset_save_from_path(app: AppHandle, path: PathBuf) -> CmdResult CmdResult CmdResult CmdResult CmdResult CmdResult<( KeyPositions, StatPositions, GraphPositions, KnobPositions, + SpritePositions, Vec, )> { let mut exported_key_positions = key_positions.clone(); let mut exported_stat_positions = stat_positions.clone(); let mut exported_graph_positions = graph_positions.clone(); let mut exported_knob_positions = knob_positions.clone(); + let mut exported_sprite_positions = sprite_positions.clone(); let mut embedded_local_images = Vec::new(); let mut path_to_image_id: HashMap = HashMap::new(); @@ -482,11 +500,29 @@ fn build_preset_image_payload( } } + for sprites in exported_sprite_positions.values_mut() { + for sprite in sprites { + rewrite_position_image_reference( + &mut sprite.base_image, + &mut embedded_local_images, + &mut path_to_image_id, + )?; + for pose in &mut sprite.poses { + rewrite_position_image_reference( + &mut pose.image_override, + &mut embedded_local_images, + &mut path_to_image_id, + )?; + } + } + } + Ok(( exported_key_positions, exported_stat_positions, exported_graph_positions, exported_knob_positions, + exported_sprite_positions, embedded_local_images, )) } @@ -701,11 +737,12 @@ mod tests { }; let key_positions = KeyPositions::from([("4key".to_string(), vec![position])]); - let (exported, _, _, _, embedded) = build_preset_image_payload( + let (exported, _, _, _, _, embedded) = build_preset_image_payload( &key_positions, &StatPositions::new(), &GraphPositions::new(), &KnobPositions::new(), + &SpritePositions::new(), ) .unwrap(); diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 67b78e346..d44137f92 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -4,7 +4,7 @@ use super::{ AppStoreData, GradientSpec, GradientStop, GraphPosition, GraphPositions, GraphType, ImageFit, ImageMode, KeyCounterAlign, KeyCounterAlignMode, KeyCounterPlacement, KeyCounters, KeyMappings, KeyPosition, KeyPositions, KeySlot, KnobPosition, KnobPositions, LayerGroups, NoteAlignment, - SlotMatch, StatPosition, StatPositions, StatType, + SlotMatch, SpritePositions, StatPosition, StatPositions, StatType, }; pub const EDITOR_SCHEMA_VERSION: u16 = 1; @@ -19,6 +19,7 @@ pub enum EditorField { StatPositions, GraphPositions, KnobPositions, + SpritePositions, LayerGroups, } @@ -31,6 +32,8 @@ pub struct EditorDocumentV1 { pub stat_positions: StatPositions, pub graph_positions: GraphPositions, pub knob_positions: KnobPositions, + #[serde(default)] + pub sprite_positions: SpritePositions, pub layer_groups: LayerGroups, } @@ -43,6 +46,7 @@ impl EditorDocumentV1 { stat_positions: store.stat_positions.clone(), graph_positions: store.graph_positions.clone(), knob_positions: store.knob_positions.clone(), + sprite_positions: store.sprite_positions.clone(), layer_groups: store.layer_groups.clone(), } } @@ -53,6 +57,7 @@ impl EditorDocumentV1 { store.stat_positions = self.stat_positions.clone(); store.graph_positions = self.graph_positions.clone(); store.knob_positions = self.knob_positions.clone(); + store.sprite_positions = self.sprite_positions.clone(); store.layer_groups = self.layer_groups.clone(); } @@ -72,6 +77,9 @@ impl EditorDocumentV1 { if let Some(value) = patch.knob_positions.as_ref() { self.knob_positions = value.clone(); } + if let Some(value) = patch.sprite_positions.as_ref() { + self.sprite_positions = value.clone(); + } if let Some(value) = patch.layer_groups.as_ref() { self.layer_groups = value.clone(); } @@ -94,6 +102,9 @@ impl EditorDocumentV1 { if self.knob_positions != next.knob_positions { fields.push(EditorField::KnobPositions); } + if self.sprite_positions != next.sprite_positions { + fields.push(EditorField::SpritePositions); + } if self.layer_groups != next.layer_groups { fields.push(EditorField::LayerGroups); } @@ -117,6 +128,9 @@ impl EditorDocumentV1 { EditorField::KnobPositions => { patch.knob_positions = Some(self.knob_positions.clone()); } + EditorField::SpritePositions => { + patch.sprite_positions = Some(self.sprite_positions.clone()); + } EditorField::LayerGroups => { patch.layer_groups = Some(self.layer_groups.clone()); } @@ -141,6 +155,8 @@ pub struct EditorPatchV1 { #[serde(default, skip_serializing_if = "Option::is_none")] pub knob_positions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub sprite_positions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub layer_groups: Option, } @@ -153,6 +169,7 @@ impl Default for EditorPatchV1 { stat_positions: None, graph_positions: None, knob_positions: None, + sprite_positions: None, layer_groups: None, } } @@ -166,6 +183,7 @@ impl EditorPatchV1 { EditorField::StatPositions => self.stat_positions.is_some(), EditorField::GraphPositions => self.graph_positions.is_some(), EditorField::KnobPositions => self.knob_positions.is_some(), + EditorField::SpritePositions => self.sprite_positions.is_some(), EditorField::LayerGroups => self.layer_groups.is_some(), } } @@ -177,6 +195,7 @@ impl EditorPatchV1 { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ] .into_iter() @@ -192,6 +211,7 @@ pub enum EditorElementTypeV1 { Stat, Graph, Knob, + Sprite, } #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -638,6 +658,9 @@ pub enum EditorFrozenElementV1 { Knob { position: KnobPosition, }, + Sprite { + position: super::ReactiveSpritePosition, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -674,15 +697,17 @@ impl EditorFrozenElementV1 { Self::Stat { position } => &position.position.id, Self::Graph { position } => &position.position.id, Self::Knob { position } => &position.position.id, + Self::Sprite { position } => &position.id, } } - pub(crate) fn position_mut(&mut self) -> &mut KeyPosition { + pub(crate) fn key_position_mut(&mut self) -> Option<&mut KeyPosition> { match self { - Self::Key { position, .. } => position, - Self::Stat { position } => &mut position.position, - Self::Graph { position } => &mut position.position, - Self::Knob { position } => &mut position.position, + Self::Key { position, .. } => Some(position), + Self::Stat { position } => Some(&mut position.position), + Self::Graph { position } => Some(&mut position.position), + Self::Knob { position } => Some(&mut position.position), + Self::Sprite { .. } => None, } } } diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index cfab337a7..fd970c131 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -185,6 +185,7 @@ pub type KeyCounters = HashMap>; pub type StatPositions = HashMap>; pub type GraphPositions = HashMap>; pub type KnobPositions = HashMap>; +pub type SpritePositions = HashMap>; const DEFAULT_GRADIENT_ANGLE: f64 = 90.0; const MAX_GRADIENT_STOPS: usize = 8; @@ -880,6 +881,189 @@ pub struct KnobPosition { pub position: KeyPosition, } +pub const SPRITE_TRANSFORM_OFFSET_MIN: f64 = -2_000.0; +pub const SPRITE_TRANSFORM_OFFSET_MAX: f64 = 2_000.0; +pub const SPRITE_TRANSFORM_ROTATION_MIN: f64 = -180.0; +pub const SPRITE_TRANSFORM_ROTATION_MAX: f64 = 180.0; +pub const SPRITE_TRANSFORM_SCALE_MIN: f64 = 0.1; +pub const SPRITE_TRANSFORM_SCALE_MAX: f64 = 10.0; +pub const SPRITE_TRANSITION_MS_MAX: u32 = 1_000; +pub const MAX_SPRITE_POSES: usize = 64; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SpriteRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl Default for SpriteRect { + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SpriteAnchor { + pub x: f64, + pub y: f64, +} + +impl Default for SpriteAnchor { + fn default() -> Self { + Self { x: 0.5, y: 0.5 } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SpriteTransform { + pub x: f64, + pub y: f64, + pub rotation: f64, + pub scale: f64, +} + +impl Default for SpriteTransform { + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + rotation: 0.0, + scale: 1.0, + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SpriteImageFit { + #[default] + Cover, + Contain, + Fill, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SpritePoseMatchMode { + #[default] + Exact, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SpriteActivation { + #[default] + WhileHeld, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SpritePose { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub pose_id: String, + pub triggers: Vec, + #[serde(default)] + pub match_mode: SpritePoseMatchMode, + pub transform: SpriteTransform, + #[serde(default)] + pub image_override: Option, +} + +impl Default for SpritePose { + fn default() -> Self { + Self { + pose_id: String::new(), + triggers: Vec::new(), + match_mode: SpritePoseMatchMode::Exact, + transform: SpriteTransform::default(), + image_override: None, + } + } +} + +fn default_sprite_transition_ms() -> u32 { + 90 +} + +fn default_sprite_transition_easing() -> String { + "cubic-bezier(0.4, 0, 0.2, 1)".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ReactiveSpritePosition { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + pub dx: f64, + pub dy: f64, + pub width: f64, + pub height: f64, + #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub z_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(default)] + pub class_name: Option, + #[serde(default)] + pub use_inline_styles: Option, + #[serde(default)] + pub base_image: Option, + #[serde(default)] + pub image_fit: Option, + pub image_rect: SpriteRect, + pub pivot: SpriteAnchor, + pub idle_transform: SpriteTransform, + #[serde(default)] + pub poses: Vec, + #[serde(default)] + pub activation: SpriteActivation, + #[serde(default = "default_sprite_transition_ms")] + pub transition_ms: u32, + #[serde(default = "default_sprite_transition_easing")] + pub transition_easing: String, +} + +impl Default for ReactiveSpritePosition { + fn default() -> Self { + Self { + id: String::new(), + dx: 0.0, + dy: 0.0, + width: 200.0, + height: 200.0, + hidden: false, + z_index: None, + layer_name: None, + group_id: None, + class_name: None, + use_inline_styles: None, + base_image: None, + image_fit: None, + image_rect: SpriteRect::default(), + pivot: SpriteAnchor::default(), + idle_transform: SpriteTransform::default(), + poses: Vec::new(), + activation: SpriteActivation::WhileHeld, + transition_ms: default_sprite_transition_ms(), + transition_easing: default_sprite_transition_easing(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "kebab-case")] #[derive(Default)] @@ -2475,6 +2659,8 @@ pub struct AppStoreData { #[serde(default)] pub knob_positions: KnobPositions, #[serde(default)] + pub sprite_positions: SpritePositions, + #[serde(default)] pub layer_groups: LayerGroups, #[serde(default)] pub key_counters: KeyCounters, @@ -2562,6 +2748,7 @@ impl Default for AppStoreData { stat_positions: StatPositions::new(), graph_positions: GraphPositions::new(), knob_positions: KnobPositions::new(), + sprite_positions: SpritePositions::new(), layer_groups: LayerGroups::new(), key_counters: KeyCounters::new(), background_color: "transparent".to_string(), @@ -2837,6 +3024,7 @@ pub struct BootstrapPayload { pub stat_positions: StatPositions, pub graph_positions: GraphPositions, pub knob_positions: KnobPositions, + pub sprite_positions: SpritePositions, pub custom_tabs: Vec, pub selected_key_type: String, pub current_mode: String, diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index 5567833d0..f317f93c6 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -54,6 +54,7 @@ const ALLOWED_WS_COMMANDS: &[&str] = &[ "stat_positions_get", "graph_positions_get", "knob_positions_get", + "sprite_positions_get", "custom_tabs_list", "sound_list", "sound_load_original", @@ -94,6 +95,7 @@ const FORWARDED_EVENTS: &[&str] = &[ "statPositions:changed", "graphPositions:changed", "knobPositions:changed", + "spritePositions:changed", "layerGroups:changed", "overlay:visibility", "overlay:lock", @@ -1393,9 +1395,10 @@ mod tests { #[test] fn websocket_allowlist_uses_exact_matching() { - assert_eq!(ALLOWED_WS_COMMANDS.len(), 32); + assert_eq!(ALLOWED_WS_COMMANDS.len(), 33); assert!(is_allowed_command("app_bootstrap")); assert!(is_allowed_command("editor_get")); + assert!(is_allowed_command("sprite_positions_get")); assert!(!is_allowed_command("css_history_get")); assert!(is_allowed_command("sound_list")); assert!(is_allowed_command("sound_load_original")); @@ -1428,12 +1431,13 @@ mod tests { #[test] fn public_overlay_events_are_forwarded_to_obs_clients() { - assert_eq!(FORWARDED_EVENTS.len(), 32); + assert_eq!(FORWARDED_EVENTS.len(), 33); for event in [ "customTabs:changed", "overlay:resized", "counterAnimation:changed", "preset:snapshot", + "spritePositions:changed", ] { assert!(FORWARDED_EVENTS.contains(&event), "missing event: {event}"); } diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs index 356bdc08f..b35b0e04d 100644 --- a/src-tauri/src/services/preview_broker.rs +++ b/src-tauri/src/services/preview_broker.rs @@ -89,6 +89,25 @@ const KEY_POSITION_PATCH_FIELDS: &[&str] = &[ "fontStrikethrough", ]; +const SPRITE_POSITION_PATCH_FIELDS: &[&str] = &[ + "x", + "y", + "rotation", + "scale", + "dx", + "dy", + "width", + "height", + "imageRect", + "pivot", + "idleTransform", + "poses", + "transitionMs", + "transitionEasing", + "imageFit", + "baseImage", +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PreviewKind { @@ -104,6 +123,7 @@ pub enum PreviewDomain { StatPosition, GraphPosition, KnobPosition, + SpritePosition, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -425,10 +445,14 @@ fn validate_publish_request(request: &PreviewPublishRequest) -> Result<(), Strin "preview target count exceeds {MAX_PREVIEW_TARGETS}" )); } + let allowed_fields = match request.domain { + PreviewDomain::SpritePosition => SPRITE_POSITION_PATCH_FIELDS, + _ => KEY_POSITION_PATCH_FIELDS, + }; if let Some(field) = request .patch .keys() - .find(|field| !KEY_POSITION_PATCH_FIELDS.contains(&field.as_str())) + .find(|field| !allowed_fields.contains(&field.as_str())) { return Err(format!("preview patch field '{field}' is not allowed")); } @@ -855,6 +879,7 @@ mod tests { PreviewDomain::StatPosition, PreviewDomain::GraphPosition, PreviewDomain::KnobPosition, + PreviewDomain::SpritePosition, ]; for (index, domain) in domains.iter().copied().enumerate() { @@ -874,6 +899,96 @@ mod tests { assert_eq!(forwarded, domains); } + #[test] + fn sprite_position_domain_validates_and_round_trips_through_subscribers() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let messages = Arc::new(Mutex::new(Vec::new())); + broker + .subscribe("observer", recording_channel(messages.clone())) + .expect("observer subscribes"); + let session_id = session_id(); + let patch = serde_json::json!({ + "x": 12, + "y": -6, + "rotation": 15, + "scale": 1.2, + "dx": 24, + "dy": -8, + "width": 320, + "height": 180, + "imageRect": { "x": 0, "y": 0, "width": 320, "height": 180 }, + "pivot": { "x": 0.5, "y": 0.75 }, + "idleTransform": { "x": 0, "y": 0, "rotation": 0, "scale": 1 }, + "poses": [{ "poseId": "pose-id", "triggers": [] }], + "transitionMs": 120, + "transitionEasing": "ease-out", + "imageFit": "contain", + "baseImage": "/images/base.png" + }); + let request: PreviewPublishRequest = serde_json::from_value(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id, + "seq": 1, + "domain": "spritePosition", + "mode": "4key", + "targets": [0], + "patch": patch + })) + .expect("spritePosition domain is accepted"); + + broker + .publish("owner", request) + .expect("sprite preview publishes"); + + let messages = messages.lock(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].domain, PreviewDomain::SpritePosition); + assert_eq!(messages[0].mode, "4key"); + assert_eq!(Value::Object(messages[0].patch.clone()), patch); + } + + #[test] + fn preview_patch_allowlist_rejects_unknown_and_cross_domain_fields() { + let broker = PreviewBroker::default(); + subscribe(&broker, "owner"); + let session_id = session_id(); + let mut unknown_sprite = request_for_domain(&session_id, 1, PreviewDomain::SpritePosition); + unknown_sprite.patch = + Map::from_iter([("unknownSpriteField".to_string(), Value::Bool(true))]); + + let unknown_error = broker.publish("owner", unknown_sprite).unwrap_err(); + + assert!(unknown_error.contains("unknownSpriteField")); + + let mut sprite_field_on_key = + request_for_domain(&session_id, 2, PreviewDomain::KeyPosition); + sprite_field_on_key.patch = Map::from_iter([( + "pivot".to_string(), + serde_json::json!({ "x": 0.5, "y": 0.5 }), + )]); + + let cross_domain_error = broker.publish("owner", sprite_field_on_key).unwrap_err(); + + assert!(cross_domain_error.contains("pivot")); + } + + #[test] + fn unknown_preview_domain_is_rejected_during_wire_validation() { + let error = serde_json::from_value::(serde_json::json!({ + "schemaVersion": PREVIEW_SCHEMA_VERSION, + "sessionId": session_id(), + "seq": 1, + "domain": "spritePose", + "mode": "4key", + "targets": [0], + "patch": { "dx": 24 } + })) + .expect_err("unknown preview domains stay closed"); + + assert!(error.to_string().contains("unknown variant")); + } + #[test] fn rejects_non_monotonic_sequence() { let broker = PreviewBroker::default(); diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 0bf41ab4a..3ee4de93d 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -1470,6 +1470,7 @@ impl AppState { stat_positions: state.stat_positions.clone(), graph_positions: state.graph_positions.clone(), knob_positions: state.knob_positions.clone(), + sprite_positions: state.sprite_positions.clone(), custom_tabs: state.custom_tabs.clone(), selected_key_type: state.selected_key_type.clone(), current_mode, diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index 0d2fd9436..80d73e1d3 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -11,8 +11,11 @@ use crate::{ models::{ AppStoreData, CustomTab, EditorBoundsV1, EditorCommitRequest, EditorDocumentV1, EditorElementTypeV1, EditorField, ElementShadowSpec, GraphPosition, KeyCounters, - KeyMappings, KeyPosition, KeySlot, KnobPosition, StatPosition, - EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + KeyMappings, KeyPosition, KeySlot, KnobPosition, ReactiveSpritePosition, SpriteTransform, + StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + MAX_SPRITE_POSES, SPRITE_TRANSFORM_OFFSET_MAX, SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN, SPRITE_TRANSFORM_SCALE_MAX, + SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX, }, }; @@ -86,6 +89,7 @@ enum NativeElementKind { Stat, Graph, Knob, + Sprite, } #[derive(Debug, Clone, Copy)] @@ -122,6 +126,14 @@ enum ViolationPropertyPath { name: &'static str, property: &'static str, }, + SpriteProperty { + section: &'static str, + property: &'static str, + }, + SpritePoseProperty { + pose_index: usize, + property: &'static str, + }, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -1171,9 +1183,30 @@ fn native_id_alias_by_slot( nested_position_ids(¤t.knob_positions), nested_position_ids(&candidate.knob_positions), ); + collect( + sprite_position_ids(¤t.sprite_positions), + sprite_position_ids(&candidate.sprite_positions), + ); alias } +fn sprite_position_ids( + collection: &HashMap>, +) -> Vec<(&String, Vec<&str>)> { + collection + .iter() + .map(|(mode, positions)| { + ( + mode, + positions + .iter() + .map(|position| position.id.as_str()) + .collect(), + ) + }) + .collect() +} + fn key_position_ids(collection: &HashMap>) -> Vec<(&String, Vec<&str>)> { collection .iter() @@ -1303,6 +1336,7 @@ fn collect_violations( .chain(document.stat_positions.keys()) .chain(document.graph_positions.keys()) .chain(document.knob_positions.keys()) + .chain(document.sprite_positions.keys()) .chain(document.layer_groups.keys()) .cloned() .collect::>(); @@ -1346,6 +1380,12 @@ fn collect_violations( allowed_modes, &mut violations, ); + collect_collection_violations( + "spritePositions", + &document.sprite_positions, + allowed_modes, + &mut violations, + ); collect_collection_violations( "layerGroups", &document.layer_groups, @@ -1407,6 +1447,7 @@ fn collect_violations( } collect_position_style_violations(document, &mut violations); + collect_sprite_violations(document, &mut violations); let group_ids = collect_group_violations(document, &mut violations); collect_group_reference_violations(document, &group_ids, &mut violations); violations @@ -1525,6 +1566,197 @@ fn collect_position_style_violations( } } +fn collect_sprite_violations( + document: &EditorDocumentV1, + violations: &mut BTreeSet, +) { + for (mode, sprites) in &document.sprite_positions { + for (sprite_index, sprite) in sprites.iter().enumerate() { + collect_sprite_transform_violations( + sprite, + mode, + sprite_index, + "idleTransform", + None, + &sprite.idle_transform, + violations, + ); + + for (property, value) in [("x", sprite.pivot.x), ("y", sprite.pivot.y)] { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_PIVOT", + ViolationPropertyPath::SpriteProperty { + section: "pivot", + property, + }, + InvalidValueSignature::FloatBits(value.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].pivot.{property} must be between 0 and 1" + ), + )); + } + } + + for (property, value, positive) in [ + ("x", sprite.image_rect.x, false), + ("y", sprite.image_rect.y, false), + ("width", sprite.image_rect.width, true), + ("height", sprite.image_rect.height, true), + ] { + if !value.is_finite() || (positive && value <= 0.0) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_RECT", + ViolationPropertyPath::SpriteProperty { + section: "imageRect", + property, + }, + InvalidValueSignature::FloatBits(value.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].imageRect.{property} must be {}", + if positive { + "a positive finite number" + } else { + "a finite number" + } + ), + )); + } + } + + if sprite.transition_ms > SPRITE_TRANSITION_MS_MAX { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_TRANSITION", + ViolationPropertyPath::SpriteProperty { + section: "transition", + property: "transitionMs", + }, + InvalidValueSignature::Count(sprite.transition_ms as usize), + ), + format!( + "spritePositions {mode}[{sprite_index}].transitionMs exceeds {SPRITE_TRANSITION_MS_MAX}" + ), + )); + } + + let mut trigger_sets = HashSet::new(); + for (pose_index, pose) in sprite.poses.iter().enumerate() { + collect_sprite_transform_violations( + sprite, + mode, + sprite_index, + "poseTransform", + Some(pose_index), + &pose.transform, + violations, + ); + if pose.triggers.is_empty() { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "EMPTY_SPRITE_POSE_TRIGGERS", + ViolationPropertyPath::SpritePoseProperty { + pose_index, + property: "triggers", + }, + InvalidValueSignature::Count(pose_index), + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].triggers must not be empty" + ), + )); + } else if !trigger_sets.insert(pose.triggers.clone()) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "DUPLICATE_SPRITE_POSE_TRIGGERS", + ViolationPropertyPath::SpritePoseProperty { + pose_index, + property: "triggers", + }, + InvalidValueSignature::Text(format!("{:?}", pose.triggers)), + ), + format!( + "spritePositions {mode}[{sprite_index}] contains duplicate pose trigger sets" + ), + )); + } + } + } + } +} + +fn collect_sprite_transform_violations( + sprite: &ReactiveSpritePosition, + mode: &str, + sprite_index: usize, + section: &'static str, + pose_index: Option, + transform: &SpriteTransform, + violations: &mut BTreeSet, +) { + for (property, value, minimum, maximum) in [ + ( + "x", + transform.x, + SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_OFFSET_MAX, + ), + ( + "y", + transform.y, + SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_OFFSET_MAX, + ), + ( + "rotation", + transform.rotation, + SPRITE_TRANSFORM_ROTATION_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, + ), + ( + "scale", + transform.scale, + SPRITE_TRANSFORM_SCALE_MIN, + SPRITE_TRANSFORM_SCALE_MAX, + ), + ] { + if !value.is_finite() || !(minimum..=maximum).contains(&value) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_TRANSFORM", + pose_index.map_or( + ViolationPropertyPath::SpriteProperty { section, property }, + |pose_index| ViolationPropertyPath::SpritePoseProperty { + pose_index, + property, + }, + ), + InvalidValueSignature::FloatBits(value.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].{section}.{property} must be between {minimum} and {maximum}" + ), + )); + } + } +} + fn collect_image_transform_violations( element: NativeElementDiagnostic<'_>, name: &'static str, @@ -1714,6 +1946,11 @@ fn validate_aggregate_metric_limits( ¤t.knob_positions, &candidate.knob_positions, )?; + validate_collection_limits( + "spritePositions", + ¤t.sprite_positions, + &candidate.sprite_positions, + )?; validate_collection_limits( "layerGroups", ¤t.layer_groups, @@ -1961,9 +2198,56 @@ fn validate_per_owner_metric_limits( } } + let current_sprite_positions = current + .sprite_positions + .values() + .flatten() + .map(|position| (position.id.as_str(), position)) + .collect::>(); + for (mode, sprites) in &candidate.sprite_positions { + for (index, sprite) in sprites.iter().enumerate() { + let current_sprite = match keying { + GrandfatherKeying::StableId => { + current_sprite_positions.get(sprite.id.as_str()).copied() + } + #[cfg(test)] + GrandfatherKeying::ModeIndex => current + .sprite_positions + .get(mode) + .and_then(|sprites| sprites.get(index)), + GrandfatherKeying::LegacyPresetModeIndex => current + .sprite_positions + .get(mode) + .and_then(|sprites| sprites.get(index)), + }; + validate_bounds_metrics( + &format!("spritePositions {mode}[{index}]"), + current_sprite.map(sprite_bounds), + sprite_bounds(sprite), + keying == GrandfatherKeying::LegacyPresetModeIndex, + )?; + validate_count_limit( + "COLLECTION_TOO_LARGE", + &format!("spritePositions {mode}[{index}] pose count"), + current_sprite.map_or(0, |sprite| sprite.poses.len()), + sprite.poses.len(), + MAX_SPRITE_POSES, + )?; + } + } + Ok(()) } +fn sprite_bounds(sprite: &ReactiveSpritePosition) -> EditorBoundsV1 { + EditorBoundsV1 { + dx: sprite.dx, + dy: sprite.dy, + width: sprite.width, + height: sprite.height, + } +} + fn validate_key_slot_label_limits( mode: &str, slot_index: usize, @@ -2007,6 +2291,7 @@ fn editor_modes(document: &EditorDocumentV1) -> BTreeSet { .chain(document.stat_positions.keys()) .chain(document.graph_positions.keys()) .chain(document.knob_positions.keys()) + .chain(document.sprite_positions.keys()) .chain(document.layer_groups.keys()) .cloned() .collect() @@ -2029,6 +2314,11 @@ fn render_item_count(document: &EditorDocumentV1) -> usize { .values() .map(Vec::len) .sum::() + + document + .sprite_positions + .values() + .map(Vec::len) + .sum::() } fn validate_collection_limits( @@ -2125,12 +2415,12 @@ fn position_bounds(position: &KeyPosition) -> EditorBoundsV1 { pub(crate) fn validate_editor_op_bounds( op_index: usize, - current: Option<&KeyPosition>, + current: Option, bounds: EditorBoundsV1, ) -> Result<(), EditorCommitError> { validate_bounds_metrics( &format!("editor op {op_index}.bounds"), - current.map(position_bounds), + current, bounds, false, ) @@ -2355,6 +2645,30 @@ fn collect_group_reference_violations( )); } } + for (mode, sprites) in &document.sprite_positions { + for (index, sprite) in sprites.iter().enumerate() { + let Some(group_id) = sprite.group_id.as_deref() else { + continue; + }; + if !group_ids + .get(mode) + .is_some_and(|ids| ids.contains(group_id)) + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "UNKNOWN_GROUP_ID", + ViolationPropertyPath::GroupReference, + InvalidValueSignature::Text(group_id.to_string()), + ), + format!( + "spritePositions {mode}[{index}] references unknown group '{group_id}'" + ), + )); + } + } + } } pub(crate) fn sync_key_counters(counters: &mut KeyCounters, keys: &KeyMappings) { @@ -3511,6 +3825,40 @@ mod tests { ); } + #[test] + fn sprite_patch_element_wire_accepts_generic_and_rejects_dedicated_fields() { + let base = serde_json::json!({ + "baseRevision": 0, + "mutationId": Uuid::new_v4().to_string(), + "opsVersion": EDITOR_OPS_VERSION, + "ops": [{ + "kind": "patchElement", + "elementType": "sprite", + "id": Uuid::new_v4().to_string(), + "patch": { "property": "hidden", "value": true }, + }], + }); + decode_editor_commit_request(base.clone()).unwrap(); + + for (property, value) in [ + ("baseImage", serde_json::json!("sprite.png")), + ("poses", serde_json::json!([])), + ("pivot", serde_json::json!({ "x": 0.5, "y": 0.5 })), + ] { + let mut wire = base.clone(); + wire["ops"][0]["patch"] = serde_json::json!({ + "property": property, + "value": value, + }); + let error = decode_editor_commit_request(wire).unwrap_err(); + assert_eq!( + validation_code(&error), + Some("INVALID_REQUEST_PAYLOAD"), + "{property} must not be a patchElement property" + ); + } + } + #[test] fn frozen_insert_wire_is_exact_through_nested_full_records() { let request = ops_request(vec![frozen_insert_op(Uuid::new_v4().to_string())]); @@ -4226,7 +4574,7 @@ mod tests { }; validate_editor_op_bounds( 0, - Some(&grandfathered), + Some(position_bounds(&grandfathered)), EditorBoundsV1 { width: MAX_DIMENSION + 1.0, ..position_bounds(&grandfathered) diff --git a/src-tauri/src/state/editor_ops.rs b/src-tauri/src/state/editor_ops.rs index c532f47f4..7a30114eb 100644 --- a/src-tauri/src/state/editor_ops.rs +++ b/src-tauri/src/state/editor_ops.rs @@ -12,17 +12,17 @@ use crate::{ EditorOpResultStatusV1, EditorOpResultV1, EditorOpV1, EditorPaintDescriptorV1, EditorPaintGradientV1, EditorShadowLeafPatchV1, EditorTargetGroupV1, EditorZUpdateV1, ElementShadowSpec, GradientSpec, ImageMode, ImageTransform, ImageTransformLeafPatchV1, - KeyPosition, LayerGroupDef, NoteColor, NoteGradientShadow, IMAGE_TRANSFORM_OFFSET_MAX, - IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, - SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + KeyPosition, LayerGroupDef, NoteColor, NoteGradientShadow, ReactiveSpritePosition, + IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, + IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, + SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, }, }; use super::editor::{ validate_document_transition, validate_editor_op_bounds, validate_editor_op_target_type, }; -use super::native_element_id::DUPLICATE_ELEMENT_ID; +use super::native_element_id::{validate_document_element_ids, DUPLICATE_ELEMENT_ID}; use super::plugin::{plugin_group_refs_from_store, PluginGroupRefs}; #[derive(Debug)] @@ -41,6 +41,19 @@ struct ElementLocation { index: usize, } +struct ElementCommonMut<'a> { + dx: &'a mut f64, + dy: &'a mut f64, + width: &'a mut f64, + height: &'a mut f64, + hidden: &'a mut bool, + z_index: &'a mut Option, + layer_name: &'a mut Option, + group_id: &'a mut Option, + class_name: &'a mut Option, + use_inline_styles: &'a mut Option, +} + fn current_counter_animation_preset( store: &AppStoreData, preset_id: &str, @@ -785,11 +798,11 @@ fn insert_location( element_type: EditorElementTypeV1, mode: &str, index: usize, - position: &KeyPosition, + id: &str, ) -> Result<(), EditorCommitError> { if locations .insert( - position.id.clone(), + id.to_string(), ElementLocation { element_type, mode: mode.to_string(), @@ -800,7 +813,7 @@ fn insert_location( { return Err(EditorCommitError::validation( DUPLICATE_ELEMENT_ID, - format!("native element ID {} is not globally unique", position.id), + format!("native element ID {id} is not globally unique"), )); } Ok(()) @@ -824,6 +837,11 @@ fn build_element_locator( .knob_positions .values() .map(Vec::len) + .sum::() + + document + .sprite_positions + .values() + .map(Vec::len) .sum::(); let mut locations = HashMap::with_capacity(element_count); @@ -834,7 +852,7 @@ fn build_element_locator( EditorElementTypeV1::Key, mode, index, - position, + &position.id, )?; } } @@ -845,7 +863,7 @@ fn build_element_locator( EditorElementTypeV1::Stat, mode, index, - &position.position, + &position.position.id, )?; } } @@ -856,7 +874,7 @@ fn build_element_locator( EditorElementTypeV1::Graph, mode, index, - &position.position, + &position.position.id, )?; } } @@ -867,7 +885,18 @@ fn build_element_locator( EditorElementTypeV1::Knob, mode, index, - &position.position, + &position.position.id, + )?; + } + } + for (mode, positions) in &document.sprite_positions { + for (index, position) in positions.iter().enumerate() { + insert_location( + &mut locations, + EditorElementTypeV1::Sprite, + mode, + index, + &position.id, )?; } } @@ -875,6 +904,75 @@ fn build_element_locator( Ok(locations) } +fn key_position_common_mut(position: &mut KeyPosition) -> ElementCommonMut<'_> { + ElementCommonMut { + dx: &mut position.dx, + dy: &mut position.dy, + width: &mut position.width, + height: &mut position.height, + hidden: &mut position.hidden, + z_index: &mut position.z_index, + layer_name: &mut position.layer_name, + group_id: &mut position.group_id, + class_name: &mut position.class_name, + use_inline_styles: &mut position.use_inline_styles, + } +} + +fn sprite_common_mut(position: &mut ReactiveSpritePosition) -> ElementCommonMut<'_> { + ElementCommonMut { + dx: &mut position.dx, + dy: &mut position.dy, + width: &mut position.width, + height: &mut position.height, + hidden: &mut position.hidden, + z_index: &mut position.z_index, + layer_name: &mut position.layer_name, + group_id: &mut position.group_id, + class_name: &mut position.class_name, + use_inline_styles: &mut position.use_inline_styles, + } +} + +fn element_common_at_mut<'a>( + document: &'a mut EditorDocumentV1, + location: &ElementLocation, +) -> Result, EditorCommitError> { + let common = match location.element_type { + EditorElementTypeV1::Key => document + .key_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(key_position_common_mut), + EditorElementTypeV1::Stat => document + .stat_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(|position| key_position_common_mut(&mut position.position)), + EditorElementTypeV1::Graph => document + .graph_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(|position| key_position_common_mut(&mut position.position)), + EditorElementTypeV1::Knob => document + .knob_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(|position| key_position_common_mut(&mut position.position)), + EditorElementTypeV1::Sprite => document + .sprite_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .map(sprite_common_mut), + }; + common.ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + }) +} + fn position_at_mut<'a>( document: &'a mut EditorDocumentV1, location: &ElementLocation, @@ -899,6 +997,7 @@ fn position_at_mut<'a>( .get_mut(&location.mode) .and_then(|positions| positions.get_mut(location.index)) .map(|position| &mut position.position), + EditorElementTypeV1::Sprite => None, }; position.ok_or_else(|| { EditorCommitError::validation( @@ -917,12 +1016,32 @@ fn bounds_of(position: &KeyPosition) -> EditorBoundsV1 { } } +fn element_bounds( + document: &EditorDocumentV1, + location: &ElementLocation, +) -> Option { + match location.element_type { + EditorElementTypeV1::Sprite => document + .sprite_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + .map(|position| EditorBoundsV1 { + dx: position.dx, + dy: position.dy, + width: position.width, + height: position.height, + }), + _ => position_at(document, location).map(bounds_of), + } +} + fn frozen_element_type(element: &EditorFrozenElementV1) -> EditorElementTypeV1 { match element { EditorFrozenElementV1::Key { .. } => EditorElementTypeV1::Key, EditorFrozenElementV1::Stat { .. } => EditorElementTypeV1::Stat, EditorFrozenElementV1::Graph { .. } => EditorElementTypeV1::Graph, EditorFrozenElementV1::Knob { .. } => EditorElementTypeV1::Knob, + EditorFrozenElementV1::Sprite { .. } => EditorElementTypeV1::Sprite, } } @@ -932,6 +1051,7 @@ fn frozen_element_group_id(element: &EditorFrozenElementV1) -> Option<&str> { EditorFrozenElementV1::Stat { position } => position.position.group_id.as_deref(), EditorFrozenElementV1::Graph { position } => position.position.group_id.as_deref(), EditorFrozenElementV1::Knob { position } => position.position.group_id.as_deref(), + EditorFrozenElementV1::Sprite { position } => position.group_id.as_deref(), } } @@ -977,6 +1097,13 @@ fn frozen_element_matches( .and_then(|positions| positions.get(location.index)) == Some(position) } + EditorFrozenElementV1::Sprite { position } => { + document + .sprite_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + == Some(position) + } } } @@ -991,8 +1118,19 @@ fn z_update_matches( update: &EditorZUpdateV1, ) -> bool { location.element_type == update.element_type - && position_at(document, location) - .is_some_and(|position| position.z_index.unwrap_or_default() == update.z_index) + && element_z_index(document, location) + .is_some_and(|z_index| z_index.unwrap_or_default() == update.z_index) +} + +fn element_z_index(document: &EditorDocumentV1, location: &ElementLocation) -> Option> { + match location.element_type { + EditorElementTypeV1::Sprite => document + .sprite_positions + .get(&location.mode) + .and_then(|positions| positions.get(location.index)) + .map(|position| position.z_index), + _ => position_at(document, location).map(|position| position.z_index), + } } fn position_at<'a>( @@ -1019,6 +1157,7 @@ fn position_at<'a>( .get(&location.mode) .and_then(|positions| positions.get(location.index)) .map(|position| &position.position), + EditorElementTypeV1::Sprite => None, } } @@ -1055,6 +1194,11 @@ fn append_frozen_element( .entry(mode.to_string()) .or_default() .push(position.clone()), + EditorFrozenElementV1::Sprite { position } => document + .sprite_positions + .entry(mode.to_string()) + .or_default() + .push(position.clone()), } } @@ -1074,8 +1218,15 @@ fn apply_frozen_insert( .iter() .cloned() .map(|mut element| { - element.position_mut().canonicalize_gradient_pairs(); - element.position_mut().canonicalize_image_mode(); + if let Some(position) = element.key_position_mut() { + position.canonicalize_gradient_pairs(); + position.canonicalize_image_mode(); + } else if let EditorFrozenElementV1::Sprite { position } = &mut element { + for pose in &mut position.poses { + pose.triggers.sort_unstable(); + pose.triggers.dedup(); + } + } element }) .collect(); @@ -1185,9 +1336,9 @@ fn apply_frozen_insert( let location = locations .get(&update.id) .expect("z target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.z_index.unwrap_or_default() != update.z_index { - position.z_index = Some(update.z_index); + let common = element_common_at_mut(&mut candidate, location)?; + if common.z_index.unwrap_or_default() != update.z_index { + *common.z_index = Some(update.z_index); } } Ok((candidate, EditorOpResultStatusV1::Applied)) @@ -1284,9 +1435,9 @@ fn apply_reorder( let location = locations .get(&update.id) .expect("reorder target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.z_index != Some(update.z_index) { - position.z_index = Some(update.z_index); + let common = element_common_at_mut(&mut candidate, location)?; + if *common.z_index != Some(update.z_index) { + *common.z_index = Some(update.z_index); changed = true; } } @@ -1294,9 +1445,9 @@ fn apply_reorder( let location = locations .get(&update.id) .expect("reorder group target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.group_id != update.group_id { - position.group_id.clone_from(&update.group_id); + let common = element_common_at_mut(&mut candidate, location)?; + if *common.group_id != update.group_id { + common.group_id.clone_from(&update.group_id); changed = true; } } @@ -1385,9 +1536,9 @@ fn apply_set_element_groups( let location = locations .get(&target.id) .expect("group target was validated above"); - let position = position_at_mut(&mut candidate, location)?; - if position.group_id.as_ref() != next_group_id { - position.group_id = next_group_id.cloned(); + let common = element_common_at_mut(&mut candidate, location)?; + if common.group_id.as_ref() != next_group_id { + *common.group_id = next_group_id.cloned(); } } remove_empty_layer_groups( @@ -1433,11 +1584,11 @@ fn apply_rename_layer_group( (candidate, EditorOpResultStatusV1::Applied) } -fn apply_bounds(position: &mut KeyPosition, bounds: &EditorBoundsV1) { - position.dx = bounds.dx; - position.dy = bounds.dy; - position.width = bounds.width; - position.height = bounds.height; +fn apply_bounds(common: ElementCommonMut<'_>, bounds: &EditorBoundsV1) { + *common.dx = bounds.dx; + *common.dy = bounds.dy; + *common.width = bounds.width; + *common.height = bounds.height; } fn delete_elements( @@ -1480,6 +1631,11 @@ fn delete_elements( positions.retain(|position| !ids.contains(&position.position.id)); } } + if let Some(ids) = delete_ids.get(&EditorElementTypeV1::Sprite) { + for positions in document.sprite_positions.values_mut() { + positions.retain(|position| !ids.contains(&position.id)); + } + } } fn remove_empty_layer_groups( @@ -1506,6 +1662,9 @@ fn remove_empty_layer_groups( for position in document.knob_positions.get(mode).into_iter().flatten() { collect(position.position.group_id.as_deref()); } + for position in document.sprite_positions.get(mode).into_iter().flatten() { + collect(position.group_id.as_deref()); + } // 플러그인 멤버만 남은 그룹도 생존 - 참조 집합은 커밋 후 상태 기준 // (gesture는 요청 동봉 plugin_changes, editor 단독은 store decode) let plugin_refs = plugin_group_refs.get(mode); @@ -1572,6 +1731,22 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( .. } = op { + if *element_type == EditorElementTypeV1::Sprite + && !matches!( + patch, + EditorElementPropertyPatchV1::Hidden(_) + | EditorElementPropertyPatchV1::LayerName(_) + | EditorElementPropertyPatchV1::ClassName(_) + | EditorElementPropertyPatchV1::UseInlineStyles(_) + ) + { + return Err(EditorCommitError::validation( + "ELEMENT_TYPE_MISMATCH", + format!( + "editor op {op_index} sprite patch only allows hidden, layerName, className, or useInlineStyles" + ), + )); + } // 속성 계열별 대상 타입 제약, variant 추가 시 컴파일 에러로 분류 강제 match patch { // 그래프 전용 @@ -1990,17 +2165,22 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( continue; }; - let position = position_at_mut(&mut candidate, location)?; - validate_editor_op_bounds(op_index, Some(position), *bounds)?; - let status = if bounds_of(position) == *bounds { + let current_bounds = element_bounds(&candidate, location).ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + })?; + validate_editor_op_bounds(op_index, Some(current_bounds), *bounds)?; + let status = if current_bounds == *bounds { EditorOpResultStatusV1::NoChange } else { - apply_bounds(position, bounds); + apply_bounds(element_common_at_mut(&mut candidate, location)?, bounds); EditorOpResultStatusV1::Applied }; op_results.push(EditorOpResultV1 { status, - bounds: Some(bounds_of(position)), + bounds: element_bounds(&candidate, location), }); } EditorOpV1::DeleteElement { element_type, id } => { @@ -2031,20 +2211,20 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( }; let changed = match patch { EditorElementPropertyPatchV1::Hidden(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.hidden == *patch { + let common = element_common_at_mut(&mut candidate, location)?; + if *common.hidden == *patch { false } else { - position.hidden = *patch; + *common.hidden = *patch; true } } EditorElementPropertyPatchV1::LayerName(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.layer_name == *patch { + let common = element_common_at_mut(&mut candidate, location)?; + if *common.layer_name == *patch { false } else { - position.layer_name.clone_from(patch); + common.layer_name.clone_from(patch); true } } @@ -2193,11 +2373,11 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( } } EditorElementPropertyPatchV1::UseInlineStyles(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.use_inline_styles == Some(*patch) { + let common = element_common_at_mut(&mut candidate, location)?; + if *common.use_inline_styles == Some(*patch) { false } else { - position.use_inline_styles = Some(*patch); + *common.use_inline_styles = Some(*patch); true } } @@ -2271,11 +2451,11 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( } } EditorElementPropertyPatchV1::ClassName(patch) => { - let position = position_at_mut(&mut candidate, location)?; - if position.class_name.as_deref() == Some(patch.as_str()) { + let common = element_common_at_mut(&mut candidate, location)?; + if common.class_name.as_deref() == Some(patch.as_str()) { false } else { - position.class_name = Some(patch.clone()); + *common.class_name = Some(patch.clone()); true } } @@ -2920,6 +3100,7 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( } delete_elements(&mut candidate, &delete_ids); remove_empty_layer_groups(&mut candidate, &delete_modes, plugin_group_refs); + validate_document_element_ids(&candidate)?; let mut scratch = current_store.clone(); candidate.apply_to_store(&mut scratch); @@ -2944,10 +3125,12 @@ mod tests { AppStoreData, EditorElementGroupTargetV1, EditorElementPropertyPatchV1, EditorFrozenElementV1, EditorFrozenGroupV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpResultStatusV1, EditorOpV1, EditorTargetGroupV1, EditorZUpdateV1, - GraphPosition, GraphStatType, GraphType, KeyPosition, KnobPosition, StatPosition, - StatType, + GraphPosition, GraphStatType, GraphType, KeyPosition, KnobPosition, LayerGroupDef, + ReactiveSpritePosition, SpritePose, StatPosition, StatType, + }, + state::native_element_id::{ + backfill_store_element_ids, DUPLICATE_ELEMENT_ID, INVALID_ELEMENT_ID, }, - state::native_element_id::backfill_store_element_ids, }; use super::*; @@ -2965,6 +3148,15 @@ mod tests { store } + fn store_with_sprite() -> AppStoreData { + let mut store = base_store(); + store + .sprite_positions + .insert("4key".to_string(), vec![ReactiveSpritePosition::default()]); + backfill_store_element_ids(&mut store); + store + } + fn insert_op(store: &AppStoreData) -> EditorOpV1 { let mut position = KeyPosition { id: uuid::Uuid::new_v4().to_string(), @@ -3022,6 +3214,9 @@ mod tests { position: KeyPosition::default(), }], ); + store + .sprite_positions + .insert("4key".to_string(), vec![ReactiveSpritePosition::default()]); backfill_store_element_ids(&mut store); store } @@ -3057,6 +3252,13 @@ mod tests { .map(|position| position.position.id.clone()) .collect(), ), + ( + EditorElementTypeV1::Sprite, + store.sprite_positions["4key"] + .iter() + .map(|position| position.id.clone()) + .collect(), + ), ] { for id in ids { z_updates.push(EditorZUpdateV1 { @@ -3229,9 +3431,10 @@ mod tests { KeyCounterAlign, KeyCounterAlignMode, KeyCounterPlacement, NoteAlignment, StatType, }; use EditorElementPropertyPatchV1 as Patch; - use EditorElementTypeV1::{Graph, Key, Knob, Stat}; + use EditorElementTypeV1::{Graph, Key, Knob, Sprite, Stat}; - const ALL: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob]; + const ALL: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob, Sprite]; + const KEY_POSITION_TYPES: &[EditorElementTypeV1] = &[Key, Stat, Graph, Knob]; const GRAPH_ONLY: &[EditorElementTypeV1] = &[Graph]; const KNOB_ONLY: &[EditorElementTypeV1] = &[Knob]; const STAT_ONLY: &[EditorElementTypeV1] = &[Stat]; @@ -3274,13 +3477,29 @@ mod tests { row("sensitivity", KNOB_ONLY, Patch::Sensitivity(1.5)), row("axisId", KNOB_ONLY, Patch::AxisId("axis-2".to_string())), row("useInlineStyles", ALL, Patch::UseInlineStyles(true)), - row("fontWeight", ALL, Patch::FontWeight(700)), - row("fontBold", ALL, Patch::FontBold(true)), - row("fontItalic", ALL, Patch::FontItalic(true)), - row("fontUnderline", ALL, Patch::FontUnderline(true)), - row("fontStrikethrough", ALL, Patch::FontStrikethrough(true)), - row("fontFamily", ALL, Patch::FontFamily("Sans".to_string())), - row("displayText", ALL, Patch::DisplayText("A".to_string())), + row("fontWeight", KEY_POSITION_TYPES, Patch::FontWeight(700)), + row("fontBold", KEY_POSITION_TYPES, Patch::FontBold(true)), + row("fontItalic", KEY_POSITION_TYPES, Patch::FontItalic(true)), + row( + "fontUnderline", + KEY_POSITION_TYPES, + Patch::FontUnderline(true), + ), + row( + "fontStrikethrough", + KEY_POSITION_TYPES, + Patch::FontStrikethrough(true), + ), + row( + "fontFamily", + KEY_POSITION_TYPES, + Patch::FontFamily("Sans".to_string()), + ), + row( + "displayText", + KEY_POSITION_TYPES, + Patch::DisplayText("A".to_string()), + ), row("className", ALL, Patch::ClassName("custom".to_string())), row( "fontPaint", @@ -3305,7 +3524,7 @@ mod tests { row("shadowEnabled", KEY_STAT_KNOB, Patch::ShadowEnabled(true)), row( "backgroundPaint", - ALL, + KEY_POSITION_TYPES, Patch::BackgroundPaint(paint_descriptor("#112233", None)), ), row( @@ -3315,7 +3534,7 @@ mod tests { ), row( "borderPaint", - ALL, + KEY_POSITION_TYPES, Patch::BorderPaint(paint_descriptor("#112233", None)), ), row( @@ -3323,12 +3542,12 @@ mod tests { KEY_KNOB, Patch::ActiveBorderPaint(paint_descriptor("#112233", None)), ), - row("borderWidth", ALL, Patch::BorderWidth(2.0)), - row("borderRadius", ALL, Patch::BorderRadius(8.0)), - row("fontSize", ALL, Patch::FontSize(16.0)), + row("borderWidth", KEY_POSITION_TYPES, Patch::BorderWidth(2.0)), + row("borderRadius", KEY_POSITION_TYPES, Patch::BorderRadius(8.0)), + row("fontSize", KEY_POSITION_TYPES, Patch::FontSize(16.0)), row( "inactiveImage", - ALL, + KEY_POSITION_TYPES, Patch::InactiveImage("idle.png".to_string()), ), row( @@ -3336,13 +3555,21 @@ mod tests { KEY_KNOB, Patch::ActiveImage("active.png".to_string()), ), - row("idleTransparent", ALL, Patch::IdleTransparent(true)), + row( + "idleTransparent", + KEY_POSITION_TYPES, + Patch::IdleTransparent(true), + ), row( "activeTransparent", KEY_KNOB, Patch::ActiveTransparent(true), ), - row("idleImageFit", ALL, Patch::IdleImageFit(ImageFit::Contain)), + row( + "idleImageFit", + KEY_POSITION_TYPES, + Patch::IdleImageFit(ImageFit::Contain), + ), row( "activeImageFit", KEY_KNOB, @@ -3491,6 +3718,7 @@ mod tests { EditorElementTypeV1::Stat => store.stat_positions["4key"][0].position.id.clone(), EditorElementTypeV1::Graph => store.graph_positions["4key"][0].position.id.clone(), EditorElementTypeV1::Knob => store.knob_positions["4key"][0].position.id.clone(), + EditorElementTypeV1::Sprite => store.sprite_positions["4key"][0].id.clone(), }; let rows = patch_target_type_matrix(); @@ -3534,6 +3762,7 @@ mod tests { EditorElementTypeV1::Stat, EditorElementTypeV1::Graph, EditorElementTypeV1::Knob, + EditorElementTypeV1::Sprite, ] { // 선언 타입과 실제 요소 타입을 일치시켜 계열 제약만 판정되게 고정 let ops = [patch_property_op( @@ -3565,6 +3794,264 @@ mod tests { } } + #[test] + fn sprite_patch_element_allows_only_generic_properties() { + let mut store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + for patch in [ + EditorElementPropertyPatchV1::Hidden(true), + EditorElementPropertyPatchV1::LayerName(Some("sprite layer".to_string())), + EditorElementPropertyPatchV1::ClassName("sprite-class".to_string()), + EditorElementPropertyPatchV1::UseInlineStyles(true), + ] { + let transition = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Sprite, + &sprite_id, + patch, + )], + ) + .unwrap(); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(transition.changed_fields, [EditorField::SpritePositions]); + store = transition.scratch; + } + + let sprite = &store.sprite_positions["4key"][0]; + assert!(sprite.hidden); + assert_eq!(sprite.layer_name.as_deref(), Some("sprite layer")); + assert_eq!(sprite.class_name.as_deref(), Some("sprite-class")); + assert_eq!(sprite.use_inline_styles, Some(true)); + + let error = prepare_editor_ops_transition( + &store, + &[patch_property_op( + EditorElementTypeV1::Sprite, + &sprite_id, + EditorElementPropertyPatchV1::InactiveImage("blocked.png".to_string()), + )], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + } + + #[test] + fn sprite_set_bounds_updates_activity_bounds() { + let store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + let bounds = EditorBoundsV1 { + dx: 24.0, + dy: -12.0, + width: 360.0, + height: 240.0, + }; + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + bounds, + }], + ) + .unwrap(); + + let sprite = &transition.candidate.sprite_positions["4key"][0]; + assert_eq!((sprite.dx, sprite.dy), (bounds.dx, bounds.dy)); + assert_eq!((sprite.width, sprite.height), (bounds.width, bounds.height)); + assert_eq!(transition.op_results[0].bounds, Some(bounds)); + assert_eq!(transition.changed_fields, [EditorField::SpritePositions]); + } + + #[test] + fn sprite_delete_element_removes_sprite_and_its_empty_group() { + let mut store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + store.sprite_positions.get_mut("4key").unwrap()[0].group_id = + Some("sprite-group".to_string()); + store.layer_groups.insert( + "4key".to_string(), + vec![LayerGroupDef { + id: "sprite-group".to_string(), + name: "Sprite Group".to_string(), + }], + ); + + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::DeleteElement { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + }], + ) + .unwrap(); + + assert!(transition.candidate.sprite_positions["4key"].is_empty()); + assert!(transition.candidate.layer_groups["4key"].is_empty()); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + } + + #[test] + fn sprite_reorder_elements_updates_z_index() { + let store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::ReorderElements { + mode: "4key".to_string(), + complete_mode_order: false, + z_updates: vec![EditorZUpdateV1 { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + z_index: 42, + }], + group_updates: Vec::new(), + }], + ) + .unwrap(); + + assert_eq!( + transition.candidate.sprite_positions["4key"][0].z_index, + Some(42) + ); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + } + + #[test] + fn sprite_set_element_groups_creates_and_assigns_group() { + let store = store_with_sprite(); + let sprite_id = store.sprite_positions["4key"][0].id.clone(); + let transition = prepare_editor_ops_transition( + &store, + &[EditorOpV1::SetElementGroups { + mode: "4key".to_string(), + targets: vec![EditorElementGroupTargetV1 { + element_type: EditorElementTypeV1::Sprite, + id: sprite_id, + }], + target_group: Some(EditorTargetGroupV1::Create { + id: "sprite-group".to_string(), + name: "Sprite Group".to_string(), + }), + }], + ) + .unwrap(); + + assert_eq!( + transition.candidate.sprite_positions["4key"][0] + .group_id + .as_deref(), + Some("sprite-group") + ); + assert_eq!(transition.candidate.layer_groups["4key"].len(), 1); + assert_eq!( + transition.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + } + + #[test] + fn sprite_insert_frozen_elements_inserts_and_replays_idempotently() { + let store = base_store(); + let sprite_id = uuid::Uuid::new_v4().to_string(); + let sprite = ReactiveSpritePosition { + id: sprite_id.clone(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![store.key_positions["4key"][0].id.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let op = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![EditorFrozenElementV1::Sprite { position: sprite }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + let wire = serde_json::to_value(&op).unwrap(); + assert_eq!(wire["elements"][0]["elementType"], "sprite"); + assert_eq!(serde_json::from_value::(wire).unwrap(), op); + + let inserted = prepare_editor_ops_transition(&store, std::slice::from_ref(&op)).unwrap(); + assert_eq!(inserted.candidate.sprite_positions["4key"][0].id, sprite_id); + assert_eq!( + inserted.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + + let replayed = + prepare_editor_ops_transition(&inserted.scratch, std::slice::from_ref(&op)).unwrap(); + assert_eq!( + replayed.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert_eq!(replayed.candidate, inserted.candidate); + } + + #[test] + fn sprite_insert_frozen_elements_rejects_invalid_pose_id() { + let store = base_store(); + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: "not-a-uuid".to_string(), + triggers: vec![store.key_positions["4key"][0].id.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let op = EditorOpV1::InsertFrozenElements { + mode: "4key".to_string(), + elements: vec![EditorFrozenElementV1::Sprite { position: sprite }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + + assert_eq!(validation_code(&error), Some(INVALID_ELEMENT_ID)); + } + + #[test] + fn sprite_insert_frozen_elements_rejects_cross_mode_duplicate_pose_id() { + let mut store = store_with_sprite(); + let duplicate_pose_id = uuid::Uuid::new_v4().to_string(); + store.sprite_positions.get_mut("4key").unwrap()[0].poses = vec![SpritePose { + pose_id: duplicate_pose_id.clone(), + triggers: vec![store.key_positions["4key"][0].id.clone()], + ..SpritePose::default() + }]; + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: duplicate_pose_id, + triggers: vec![store.key_positions["5key"][0].id.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let op = EditorOpV1::InsertFrozenElements { + mode: "5key".to_string(), + elements: vec![EditorFrozenElementV1::Sprite { position: sprite }], + groups: Vec::new(), + z_updates: Vec::new(), + }; + + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + + assert_eq!(validation_code(&error), Some(DUPLICATE_ELEMENT_ID)); + } + #[test] fn frozen_insert_appends_key_pair_group_and_existing_z_as_one_transition() { let store = base_store(); @@ -3891,6 +4378,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ] ); diff --git a/src-tauri/src/state/history.rs b/src-tauri/src/state/history.rs index 913ecf24b..e4a3868ef 100644 --- a/src-tauri/src/state/history.rs +++ b/src-tauri/src/state/history.rs @@ -285,6 +285,9 @@ fn preserve_editor_before_values(merged: &mut EditorPatchV1, first: &EditorPatch if first.knob_positions.is_some() { merged.knob_positions.clone_from(&first.knob_positions); } + if first.sprite_positions.is_some() { + merged.sprite_positions.clone_from(&first.sprite_positions); + } if first.layer_groups.is_some() { merged.layer_groups.clone_from(&first.layer_groups); } diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 8bd29bbb5..784594b83 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -31,10 +31,11 @@ use crate::{ GradientSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, ImageMode, ImageTransform, JsPlugin, KeyCounters, KeyMappings, KeyPosition, KeyPositions, KeySlot, KnobPosition, KnobPositions, LayerGroupDef, LayerGroups, NoteSettings, - ShortcutsState, SoundLibraryEntry, StatPosition, StatPositions, StatType, - StoredOverlayBounds, TabCss, TabNoteSettings, IMAGE_TRANSFORM_OFFSET_MAX, - IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, + ReactiveSpritePosition, ShortcutsState, SoundLibraryEntry, SpritePositions, StatPosition, + StatPositions, StatType, StoredOverlayBounds, TabCss, TabNoteSettings, + IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, + IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, + POSITION_COLLECTION_FIELDS, }, services::font_metadata::parse_font_metadata, }; @@ -127,6 +128,7 @@ pub(crate) fn load_store_from_path(path: &Path) -> Result { needs_persist |= layout_repaired; needs_persist |= normalize_blank_font_colors(&mut data); needs_persist |= canonicalize_image_modes(&mut data); + needs_persist |= normalize_sprite_triggers(&mut data); let (gradient_changed, gradient_pair_repaired) = canonicalize_gradient_pairs(&mut data); needs_persist |= gradient_changed; @@ -510,6 +512,14 @@ pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut App option_has_non_empty_text(&knob_position.position.active_image) || option_has_non_empty_text(&knob_position.position.inactive_image) }) + }) || data.sprite_positions.values().any(|sprites| { + sprites.iter().any(|sprite| { + option_has_non_empty_text(&sprite.base_image) + || sprite + .poses + .iter() + .any(|pose| option_has_non_empty_text(&pose.image_override)) + }) }); if !has_any_images { @@ -572,6 +582,16 @@ pub(crate) fn migrate_key_images_to_app_data(app_data_dir: &Path, data: &mut App } } + for sprites in data.sprite_positions.values_mut() { + for sprite in sprites { + changed |= migrate_image_reference_to_app_data(&images_dir, &mut sprite.base_image); + for pose in &mut sprite.poses { + changed |= + migrate_image_reference_to_app_data(&images_dir, &mut pose.image_override); + } + } + } + changed } @@ -602,6 +622,14 @@ pub(crate) fn rehome_foreign_asset_references( changed |= rehome_position_asset_references(app_data_dir, &mut position.position); } } + for sprites in data.sprite_positions.values_mut() { + for sprite in sprites { + changed |= rehome_optional_asset_reference(app_data_dir, &mut sprite.base_image); + for pose in &mut sprite.poses { + changed |= rehome_optional_asset_reference(app_data_dir, &mut pose.image_override); + } + } + } for font in data .font_settings @@ -901,6 +929,7 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { repair_editor_revision(&mut data); normalize_blank_font_colors(&mut data); canonicalize_image_modes(&mut data); + normalize_sprite_triggers(&mut data); if data.keys.is_empty() { data.keys = default_keys().clone(); @@ -988,6 +1017,19 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData { data } +pub(crate) fn normalize_sprite_triggers(data: &mut AppStoreData) -> bool { + let mut changed = false; + for sprite in data.sprite_positions.values_mut().flatten() { + for pose in &mut sprite.poses { + let original = pose.triggers.clone(); + pose.triggers.sort_unstable(); + pose.triggers.dedup(); + changed |= pose.triggers != original; + } + } + changed +} + fn repair_image_transforms(data: &mut AppStoreData) -> bool { fn repair_transform(transform: &mut Option) -> bool { let Some(transform) = transform else { @@ -1215,6 +1257,7 @@ pub(crate) fn clear_dangling_group_ids(data: &mut AppStoreData) -> bool { &mut data.stat_positions, &mut data.graph_positions, &mut data.knob_positions, + &mut data.sprite_positions, &data.layer_groups, ) } @@ -1224,6 +1267,7 @@ pub(crate) fn clear_dangling_group_ids_in( stat_positions: &mut StatPositions, graph_positions: &mut GraphPositions, knob_positions: &mut KnobPositions, + sprite_positions: &mut SpritePositions, layer_groups: &LayerGroups, ) -> bool { let valid_ids: HashMap<&str, HashSet<&str>> = layer_groups @@ -1277,6 +1321,14 @@ pub(crate) fn clear_dangling_group_ids_in( } } } + for (tab, sprites) in sprite_positions.iter_mut() { + for sprite in sprites { + if is_dangling(tab, &sprite.group_id) { + sprite.group_id = None; + changed = true; + } + } + } changed } @@ -1492,6 +1544,7 @@ fn recover_collection_field(field: &str, value: &Value) -> Option { value, has_valid_knob_identity, ), + "spritePositions" => recover_position_entries::(field, value), "layerGroups" => recover_position_entries::(field, value), "keyCounters" => recover_key_counter_entries(value), "customCss" => recover_object_fields::(field, value), @@ -2258,9 +2311,10 @@ struct LegacyOverlayPosition { mod tests { use super::{ load_store_from_path, migrate_local_fonts_to_app_data, migrate_sound_library_enabled, - normalize_state, parse_portable_asset_reference, recover_key_mapping_entries, - rehome_foreign_asset_references, repair_image_transforms, rgba_to_hex, AssetCategory, - LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, LEGACY_PANEL_DETACH_ENABLED_KEY, + normalize_state, parse_portable_asset_reference, recover_collection_field, + recover_key_mapping_entries, rehome_foreign_asset_references, repair_image_transforms, + rgba_to_hex, AssetCategory, LEGACY_OVERLAY_HEIGHT, LEGACY_OVERLAY_WIDTH, + LEGACY_PANEL_DETACH_ENABLED_KEY, }; use crate::{ defaults::{default_keys, default_positions}, @@ -2268,8 +2322,9 @@ mod tests { AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, FontType, GraphPosition, GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyMappings, KeyPosition, KeySlot, KnobPosition, - LayerGroupDef, NoteColor, OverlayBounds, SlotMatch, SoundLibraryEntry, StatPosition, - StatType, StoredOverlayBounds, StoredOverlayNativePosition, TabCss, TabNoteSettings, + LayerGroupDef, NoteColor, OverlayBounds, ReactiveSpritePosition, SlotMatch, + SoundLibraryEntry, SpritePose, SpritePositions, StatPosition, StatType, + StoredOverlayBounds, StoredOverlayNativePosition, TabCss, TabNoteSettings, POSITION_COLLECTION_FIELDS, }, }; @@ -2297,6 +2352,45 @@ mod tests { } } + #[test] + fn sprite_collection_recovery_keeps_valid_entries_and_drops_invalid_entries() { + let valid = serde_json::to_value(ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + ..ReactiveSpritePosition::default() + }) + .unwrap(); + let recovered = recover_collection_field( + "spritePositions", + &serde_json::json!({ "4key": [valid, { "width": "invalid" }] }), + ) + .unwrap(); + let sprites: SpritePositions = serde_json::from_value(recovered).unwrap(); + + assert_eq!(sprites["4key"].len(), 1); + } + + #[test] + fn sprite_trigger_sets_are_sorted_and_deduplicated_during_normalization() { + let mut data = AppStoreData::default(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + poses: vec![SpritePose { + triggers: vec!["b".to_string(), "a".to_string(), "a".to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + + let normalized = normalize_state(data); + + assert_eq!( + normalized.sprite_positions["4key"][0].poses[0].triggers, + ["a", "b"] + ); + } + #[test] fn missing_note_gradient_multipliers_default_to_one_hundred_on_all_collections() { let gradient = serde_json::json!({ diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index 18cedea76..271556d99 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -6,8 +6,8 @@ use crate::{ errors::EditorCommitError, models::{ AppStoreData, EditorDocumentV1, EditorPatchV1, GraphPosition, KeyMappings, KeyPosition, - KeySlot, KnobPosition, StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, - EDITOR_SCHEMA_VERSION, + KeySlot, KnobPosition, ReactiveSpritePosition, StatPosition, + EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, }, }; @@ -22,47 +22,57 @@ pub(crate) struct BackfillOutcome { } trait NativeElement: Clone + PartialEq { - fn position(&self) -> &KeyPosition; - fn position_mut(&mut self) -> &mut KeyPosition; + fn id(&self) -> &str; + fn id_mut(&mut self) -> &mut String; } impl NativeElement for KeyPosition { - fn position(&self) -> &KeyPosition { - self + fn id(&self) -> &str { + &self.id } - fn position_mut(&mut self) -> &mut KeyPosition { - self + fn id_mut(&mut self) -> &mut String { + &mut self.id } } impl NativeElement for StatPosition { - fn position(&self) -> &KeyPosition { - &self.position + fn id(&self) -> &str { + &self.position.id } - fn position_mut(&mut self) -> &mut KeyPosition { - &mut self.position + fn id_mut(&mut self) -> &mut String { + &mut self.position.id } } impl NativeElement for GraphPosition { - fn position(&self) -> &KeyPosition { - &self.position + fn id(&self) -> &str { + &self.position.id } - fn position_mut(&mut self) -> &mut KeyPosition { - &mut self.position + fn id_mut(&mut self) -> &mut String { + &mut self.position.id } } impl NativeElement for KnobPosition { - fn position(&self) -> &KeyPosition { - &self.position + fn id(&self) -> &str { + &self.position.id } - fn position_mut(&mut self) -> &mut KeyPosition { - &mut self.position + fn id_mut(&mut self) -> &mut String { + &mut self.position.id + } +} + +impl NativeElement for ReactiveSpritePosition { + fn id(&self) -> &str { + &self.id + } + + fn id_mut(&mut self) -> &mut String { + &mut self.id } } @@ -104,8 +114,8 @@ fn backfill_collection( continue; }; for element in elements { - let id = &element.position().id; - if is_valid_element_id(id) && seen.insert(id.clone()) { + let id = element.id(); + if is_valid_element_id(id) && seen.insert(id.to_string()) { continue; } @@ -115,12 +125,43 @@ fn backfill_collection( } let id = new_unique_id(reserved); seen.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; outcome.changed = true; } } } +fn backfill_sprite_pose_ids( + collection: &mut HashMap>, + mode: Option<&str>, + seen: &mut HashSet, + reserved: &mut HashSet, + outcome: &mut BackfillOutcome, +) { + for collection_mode in sorted_modes(collection) { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + let Some(sprites) = collection.get_mut(&collection_mode) else { + continue; + }; + for sprite in sprites { + for pose in &mut sprite.poses { + if is_valid_element_id(&pose.pose_id) && seen.insert(pose.pose_id.clone()) { + continue; + } + if !pose.pose_id.is_empty() { + outcome.repaired = true; + } + let id = new_unique_id(reserved); + seen.insert(id.clone()); + pose.pose_id = id; + outcome.changed = true; + } + } + } +} + pub(crate) fn backfill_store_element_ids(store: &mut AppStoreData) -> BackfillOutcome { let mut seen = HashSet::new(); let mut reserved = collect_store_ids(store); @@ -149,6 +190,19 @@ pub(crate) fn backfill_store_element_ids(store: &mut AppStoreData) -> BackfillOu &mut reserved, &mut outcome, ); + backfill_collection( + &mut store.sprite_positions, + &mut seen, + &mut reserved, + &mut outcome, + ); + backfill_sprite_pose_ids( + &mut store.sprite_positions, + None, + &mut seen, + &mut reserved, + &mut outcome, + ); outcome } @@ -158,8 +212,8 @@ fn collect_collection_ids( ) { for elements in collection.values() { for element in elements { - if is_valid_element_id(&element.position().id) { - ids.insert(element.position().id.clone()); + if is_valid_element_id(element.id()) { + ids.insert(element.id().to_string()); } } } @@ -171,6 +225,14 @@ fn collect_store_ids(store: &AppStoreData) -> HashSet { collect_collection_ids(&store.stat_positions, &mut ids); collect_collection_ids(&store.graph_positions, &mut ids); collect_collection_ids(&store.knob_positions, &mut ids); + collect_collection_ids(&store.sprite_positions, &mut ids); + for sprite in store.sprite_positions.values().flatten() { + for pose in &sprite.poses { + if is_valid_element_id(&pose.pose_id) { + ids.insert(pose.pose_id.clone()); + } + } + } ids } @@ -181,7 +243,72 @@ fn rekey_collection( for mode in sorted_modes(collection) { if let Some(elements) = collection.get_mut(&mode) { for element in elements { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); + } + } + } +} + +fn rekey_key_collection( + collection: &mut HashMap>, + mode: Option<&str>, + reserved: &mut HashSet, +) -> HashMap { + let mut replacements = HashMap::new(); + for collection_mode in sorted_modes(collection) { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + let Some(elements) = collection.get_mut(&collection_mode) else { + continue; + }; + for element in elements { + let old_id = element.id.clone(); + let new_id = new_unique_id(reserved); + if !old_id.is_empty() { + replacements.insert(old_id, new_id.clone()); + } + element.id = new_id; + } + } + replacements +} + +fn rekey_sprite_pose_ids( + collection: &mut HashMap>, + mode: Option<&str>, + reserved: &mut HashSet, +) { + for collection_mode in sorted_modes(collection) { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + if let Some(sprites) = collection.get_mut(&collection_mode) { + for sprite in sprites { + for pose in &mut sprite.poses { + pose.pose_id = new_unique_id(reserved); + } + } + } + } +} + +fn remap_sprite_triggers( + collection: &mut HashMap>, + mode: Option<&str>, + replacements: &HashMap, +) { + for (collection_mode, sprites) in collection { + if mode.is_some_and(|mode| mode != collection_mode) { + continue; + } + for sprite in sprites { + for pose in &mut sprite.poses { + for trigger in &mut pose.triggers { + if let Some(replacement) = replacements.get(trigger) { + trigger.clone_from(replacement); + } + } } } } @@ -189,10 +316,13 @@ fn rekey_collection( pub(crate) fn rekey_store_element_ids(store: &mut AppStoreData) { let mut reserved = collect_store_ids(store); - rekey_collection(&mut store.key_positions, &mut reserved); + let key_replacements = rekey_key_collection(&mut store.key_positions, None, &mut reserved); rekey_collection(&mut store.stat_positions, &mut reserved); rekey_collection(&mut store.graph_positions, &mut reserved); rekey_collection(&mut store.knob_positions, &mut reserved); + rekey_collection(&mut store.sprite_positions, &mut reserved); + rekey_sprite_pose_ids(&mut store.sprite_positions, None, &mut reserved); + remap_sprite_triggers(&mut store.sprite_positions, None, &key_replacements); } fn rekey_collection_mode( @@ -202,13 +332,13 @@ fn rekey_collection_mode( ) { if let Some(elements) = collection.get_mut(mode) { for element in elements { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); } } } pub(crate) fn rekey_mode_element_ids(store: &mut AppStoreData, mode: &str) { - rekey_mode_element_ids_for_collections(store, mode, true, true, true, true); + rekey_mode_element_ids_for_collections(store, mode, true, true, true, true, true); } pub(crate) fn rekey_mode_element_ids_for_collections( @@ -218,10 +348,13 @@ pub(crate) fn rekey_mode_element_ids_for_collections( stat_positions: bool, graph_positions: bool, knob_positions: bool, + sprite_positions: bool, ) { let mut reserved = collect_store_ids(store); + let mut key_replacements = HashMap::new(); if key_positions { - rekey_collection_mode(&mut store.key_positions, mode, &mut reserved); + key_replacements = + rekey_key_collection(&mut store.key_positions, Some(mode), &mut reserved); } if stat_positions { rekey_collection_mode(&mut store.stat_positions, mode, &mut reserved); @@ -232,6 +365,11 @@ pub(crate) fn rekey_mode_element_ids_for_collections( if knob_positions { rekey_collection_mode(&mut store.knob_positions, mode, &mut reserved); } + if sprite_positions { + rekey_collection_mode(&mut store.sprite_positions, mode, &mut reserved); + rekey_sprite_pose_ids(&mut store.sprite_positions, Some(mode), &mut reserved); + } + remap_sprite_triggers(&mut store.sprite_positions, Some(mode), &key_replacements); } fn backfill_collection_mode( @@ -245,8 +383,8 @@ fn backfill_collection_mode( return; }; for element in elements { - let id = &element.position().id; - if is_valid_element_id(id) && seen.insert(id.clone()) { + let id = element.id(); + if is_valid_element_id(id) && seen.insert(id.to_string()) { continue; } @@ -256,7 +394,7 @@ fn backfill_collection_mode( } let id = new_unique_id(reserved); seen.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; outcome.changed = true; } } @@ -270,12 +408,31 @@ pub(crate) fn backfill_mode_element_ids_for_collections( stat_positions: bool, graph_positions: bool, knob_positions: bool, + sprite_positions: bool, ) -> BackfillOutcome { let mut seen = HashSet::new(); collect_collection_ids_outside_target(&store.key_positions, mode, key_positions, &mut seen); collect_collection_ids_outside_target(&store.stat_positions, mode, stat_positions, &mut seen); collect_collection_ids_outside_target(&store.graph_positions, mode, graph_positions, &mut seen); collect_collection_ids_outside_target(&store.knob_positions, mode, knob_positions, &mut seen); + collect_collection_ids_outside_target( + &store.sprite_positions, + mode, + sprite_positions, + &mut seen, + ); + for (collection_mode, sprites) in &store.sprite_positions { + if sprite_positions && collection_mode == mode { + continue; + } + for sprite in sprites { + for pose in &sprite.poses { + if is_valid_element_id(&pose.pose_id) { + seen.insert(pose.pose_id.clone()); + } + } + } + } let mut reserved = collect_store_ids(store); let mut outcome = BackfillOutcome::default(); if key_positions { @@ -314,6 +471,22 @@ pub(crate) fn backfill_mode_element_ids_for_collections( &mut outcome, ); } + if sprite_positions { + backfill_collection_mode( + &mut store.sprite_positions, + mode, + &mut seen, + &mut reserved, + &mut outcome, + ); + backfill_sprite_pose_ids( + &mut store.sprite_positions, + Some(mode), + &mut seen, + &mut reserved, + &mut outcome, + ); + } outcome } @@ -329,8 +502,8 @@ fn collect_collection_ids_outside_target( continue; } for element in elements { - if is_valid_element_id(&element.position().id) { - ids.insert(element.position().id.clone()); + if is_valid_element_id(element.id()) { + ids.insert(element.id().to_string()); } } } @@ -346,7 +519,7 @@ fn validate_supplied_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if id.is_empty() { if require_id { return Err(EditorCommitError::validation( @@ -362,7 +535,7 @@ fn validate_supplied_collection_ids( format!("native element {mode}[{index}] has an invalid ID"), )); } - if !seen.insert(id.clone()) { + if !seen.insert(id.to_string()) { return Err(EditorCommitError::validation( DUPLICATE_ELEMENT_ID, format!("native element ID {id} appears more than once in the commit"), @@ -373,6 +546,51 @@ fn validate_supplied_collection_ids( Ok(()) } +fn validate_sprite_pose_ids( + collection: &HashMap>, + require_id: bool, + seen: &mut HashSet, +) -> Result<(), EditorCommitError> { + for mode in sorted_modes(collection) { + let Some(sprites) = collection.get(&mode) else { + continue; + }; + for (sprite_index, sprite) in sprites.iter().enumerate() { + for (pose_index, pose) in sprite.poses.iter().enumerate() { + if pose.pose_id.is_empty() { + if require_id { + return Err(EditorCommitError::validation( + MISSING_ELEMENT_ID, + format!( + "sprite pose {mode}[{sprite_index}].poses[{pose_index}] is missing an ID" + ), + )); + } + continue; + } + if !is_valid_element_id(&pose.pose_id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!( + "sprite pose {mode}[{sprite_index}].poses[{pose_index}] has an invalid ID" + ), + )); + } + if !seen.insert(pose.pose_id.clone()) { + return Err(EditorCommitError::validation( + DUPLICATE_ELEMENT_ID, + format!( + "native element ID {} appears more than once in the commit", + pose.pose_id + ), + )); + } + } + } + } + Ok(()) +} + fn validate_supplied_patch_ids( patch: &EditorPatchV1, require_id: bool, @@ -390,6 +608,10 @@ fn validate_supplied_patch_ids( if let Some(collection) = patch.knob_positions.as_ref() { validate_supplied_collection_ids(collection, require_id, &mut seen)?; } + if let Some(collection) = patch.sprite_positions.as_ref() { + validate_supplied_collection_ids(collection, require_id, &mut seen)?; + validate_sprite_pose_ids(collection, require_id, &mut seen)?; + } Ok(seen) } @@ -406,9 +628,9 @@ fn sanitize_v1_supplied_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if !id.is_empty() { - owned.insert(id.clone(), (mode.clone(), index)); + owned.insert(id.to_string(), (mode.clone(), index)); } } } @@ -419,7 +641,7 @@ fn sanitize_v1_supplied_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if id.is_empty() { continue; } @@ -430,7 +652,7 @@ fn sanitize_v1_supplied_collection_ids( )); } occurrences - .entry(id.clone()) + .entry(id.to_string()) .or_default() .push((mode.clone(), index)); } @@ -461,7 +683,7 @@ fn sanitize_v1_supplied_collection_ids( .get_mut(&mode) .and_then(|elements| elements.get_mut(index)) { - element.position_mut().id.clear(); + element.id_mut().clear(); } } Ok(()) @@ -483,14 +705,78 @@ fn sanitize_v1_supplied_patch_ids( if let Some(collection) = patch.knob_positions.as_mut() { sanitize_v1_supplied_collection_ids(collection, &store.knob_positions)?; } + if let Some(collection) = patch.sprite_positions.as_mut() { + sanitize_v1_supplied_collection_ids(collection, &store.sprite_positions)?; + let owned = store + .sprite_positions + .values() + .flatten() + .flat_map(|sprite| sprite.poses.iter().map(|pose| pose.pose_id.clone())) + .collect::>(); + let mut seen = HashSet::new(); + for (mode, sprites) in collection { + for (sprite_index, sprite) in sprites.iter_mut().enumerate() { + for (pose_index, pose) in sprite.poses.iter_mut().enumerate() { + if pose.pose_id.is_empty() { + continue; + } + if !is_valid_element_id(&pose.pose_id) { + return Err(EditorCommitError::validation( + INVALID_ELEMENT_ID, + format!( + "sprite pose {mode}[{sprite_index}].poses[{pose_index}] has an invalid ID" + ), + )); + } + if !owned.contains(&pose.pose_id) || !seen.insert(pose.pose_id.clone()) { + pose.pose_id.clear(); + } + } + } + } + } Ok(()) } +fn adapt_v1_sprite_pose_ids( + current: &HashMap>, + candidate: &mut HashMap>, + consumed_current_ids: &mut HashSet, + reserved: &mut HashSet, +) { + for mode in sorted_modes(candidate) { + let Some(sprites) = candidate.get_mut(&mode) else { + continue; + }; + let current_sprites = current.get(&mode); + for sprite in sprites { + let current_sprite = current_sprites + .and_then(|sprites| sprites.iter().find(|current| current.id == sprite.id)); + for (pose_index, pose) in sprite.poses.iter_mut().enumerate() { + if !pose.pose_id.is_empty() { + consumed_current_ids.insert(pose.pose_id.clone()); + continue; + } + let inherited = current_sprite + .and_then(|sprite| sprite.poses.get(pose_index)) + .map(|pose| pose.pose_id.clone()) + .filter(|id| is_valid_element_id(id) && !consumed_current_ids.contains(id)); + if let Some(id) = inherited { + consumed_current_ids.insert(id.clone()); + pose.pose_id = id; + } else { + pose.pose_id = new_unique_id(reserved); + } + } + } + } +} + /// id를 비운 값 사본. 비교마다 양쪽을 clone하지 않도록, 스캔 전에 한 번씩만 /// 만들어 재사용한다 (승계 웨이브의 순서·우선순위는 그대로 유지) fn value_without_id(element: &T) -> T { let mut copy = element.clone(); - copy.position_mut().id.clear(); + copy.id_mut().clear(); copy } @@ -515,14 +801,14 @@ fn keep_or_rekey_supplied_ids( continue; }; for element in elements { - let id = element.position().id.clone(); + let id = element.id().to_string(); if id.is_empty() { continue; } if canonical_ids.contains(&id) { consumed_current_ids.insert(id); } else { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); } } } @@ -544,7 +830,7 @@ fn inherit_ids_by_value_within_mode( }; let normalized_current: Vec = current_elements.iter().map(value_without_id).collect(); for element in elements { - if !element.position().id.is_empty() { + if !element.id().is_empty() { continue; } let target = value_without_id(&*element); @@ -552,14 +838,14 @@ fn inherit_ids_by_value_within_mode( .iter() .zip(&normalized_current) .find(|(current_element, normalized)| { - let current_id = ¤t_element.position().id; + let current_id = current_element.id(); !consumed_current_ids.contains(current_id) && **normalized == target }) .map(|(current_element, _)| current_element); if let Some(current_element) = inherited { - let id = current_element.position().id.clone(); + let id = current_element.id().to_string(); consumed_current_ids.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; } } } @@ -577,7 +863,7 @@ fn inherit_ids_by_value( continue; }; for element in elements { - if !element.position().id.is_empty() { + if !element.id().is_empty() { continue; } let target = value_without_id(&*element); @@ -585,16 +871,16 @@ fn inherit_ids_by_value( .iter() .zip(&normalized_current) .find(|(current_element, normalized)| { - let current_id = ¤t_element.position().id; + let current_id = current_element.id(); !consumed_current_ids.contains(current_id) && **normalized == target }) .map(|(current_element, _)| current_element); if let Some(current_element) = inherited { - let id = current_element.position().id.clone(); + let id = current_element.id().to_string(); consumed_current_ids.insert(id.clone()); - element.position_mut().id = id; + *element.id_mut() = id; } else { - element.position_mut().id = new_unique_id(reserved); + *element.id_mut() = new_unique_id(reserved); } } } @@ -887,6 +1173,21 @@ fn adapt_v1_patch_ids( &mut reserved, ); } + if let Some(collection) = patch.sprite_positions.as_mut() { + adapt_v1_collection( + &store.sprite_positions, + collection, + &canonical_ids, + &mut consumed_current_ids, + &mut reserved, + ); + adapt_v1_sprite_pose_ids( + &store.sprite_positions, + collection, + &mut consumed_current_ids, + &mut reserved, + ); + } Ok(()) } @@ -899,7 +1200,7 @@ fn validate_document_collection_ids( continue; }; for (index, element) in elements.iter().enumerate() { - let id = &element.position().id; + let id = element.id(); if id.is_empty() { return Err(EditorCommitError::validation( MISSING_ELEMENT_ID, @@ -912,7 +1213,7 @@ fn validate_document_collection_ids( format!("native element {mode}[{index}] has an invalid ID"), )); } - if !seen.insert(id.clone()) { + if !seen.insert(id.to_string()) { return Err(EditorCommitError::validation( DUPLICATE_ELEMENT_ID, format!("native element ID {id} is not globally unique"), @@ -930,7 +1231,9 @@ pub(crate) fn validate_document_element_ids( validate_document_collection_ids(&document.key_positions, &mut seen)?; validate_document_collection_ids(&document.stat_positions, &mut seen)?; validate_document_collection_ids(&document.graph_positions, &mut seen)?; - validate_document_collection_ids(&document.knob_positions, &mut seen) + validate_document_collection_ids(&document.knob_positions, &mut seen)?; + validate_document_collection_ids(&document.sprite_positions, &mut seen)?; + validate_sprite_pose_ids(&document.sprite_positions, true, &mut seen) } fn patch_includes_native_elements(patch: &EditorPatchV1) -> bool { @@ -938,6 +1241,7 @@ fn patch_includes_native_elements(patch: &EditorPatchV1) -> bool { || patch.stat_positions.is_some() || patch.graph_positions.is_some() || patch.knob_positions.is_some() + || patch.sprite_positions.is_some() } pub(crate) fn prepare_commit_patch_element_ids( @@ -972,7 +1276,7 @@ mod tests { use crate::models::{ AppStoreData, EditorPatchV1, GraphPosition, GraphStatType, GraphType, KeyPosition, - KnobPosition, SlotMatch, StatPosition, StatType, + KnobPosition, ReactiveSpritePosition, SlotMatch, SpritePose, StatPosition, StatType, }; use super::*; @@ -1067,6 +1371,39 @@ mod tests { .unwrap() } + #[test] + fn backfill_assigns_globally_unique_sprite_and_pose_ids() { + let duplicate = uuid::Uuid::new_v4().to_string(); + let mut store = AppStoreData::default(); + store.sprite_positions.insert( + "mode".to_string(), + vec![ReactiveSpritePosition { + id: duplicate.clone(), + poses: vec![ + SpritePose { + pose_id: duplicate, + ..SpritePose::default() + }, + SpritePose::default(), + ], + ..ReactiveSpritePosition::default() + }], + ); + + let outcome = backfill_store_element_ids(&mut store); + let sprite = &store.sprite_positions["mode"][0]; + let ids = [ + sprite.id.as_str(), + sprite.poses[0].pose_id.as_str(), + sprite.poses[1].pose_id.as_str(), + ]; + + assert!(outcome.changed); + assert!(outcome.repaired); + assert!(ids.iter().all(|id| is_valid_element_id(id))); + assert_eq!(ids.into_iter().collect::>().len(), 3); + } + #[test] fn backfill_replaces_only_missing_invalid_and_duplicate_ids() { let mut store = store_with_all_collections(); @@ -1176,6 +1513,60 @@ mod tests { ); } + #[test] + fn v2_sprite_patch_requires_valid_globally_unique_pose_ids() { + let mut store = store_with_all_collections(); + store.sprite_positions.insert( + "mode".to_string(), + vec![ReactiveSpritePosition { + poses: vec![SpritePose::default()], + ..ReactiveSpritePosition::default() + }], + ); + rekey_store_element_ids(&mut store); + let valid = EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(store.sprite_positions.clone()), + ..EditorPatchV1::default() + }; + + for invalid_pose_id in [Uuid::nil().to_string(), "not-a-uuid".to_string()] { + let mut invalid = valid.clone(); + invalid + .sprite_positions + .as_mut() + .unwrap() + .get_mut("mode") + .unwrap()[0] + .poses[0] + .pose_id = invalid_pose_id; + assert_eq!( + validation_code( + prepare_commit_patch_element_ids(&store, &mut invalid).unwrap_err() + ), + INVALID_ELEMENT_ID + ); + } + + let duplicate_pose_id = store.sprite_positions["mode"][0].poses[0].pose_id.clone(); + let mut duplicate = valid; + duplicate.sprite_positions.as_mut().unwrap().insert( + "other-mode".to_string(), + vec![ReactiveSpritePosition { + id: Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: duplicate_pose_id, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + assert_eq!( + validation_code(prepare_commit_patch_element_ids(&store, &mut duplicate).unwrap_err()), + DUPLICATE_ELEMENT_ID + ); + } + #[test] fn v1_preserves_explicit_current_ids_and_rekeys_stale_ids() { let store = store_with_all_collections(); diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index d23b7e8d9..78faefd25 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -1686,6 +1686,7 @@ impl AppStore { target.apply_override_patches(&mut scratch); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); let candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update(¤t, &candidate, true, true)?; scratch.editor_revision = current_store.editor_revision; @@ -1786,6 +1787,7 @@ impl AppStore { ); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); let candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update(¤t, &candidate, true, true)?; scratch.editor_revision = current_store.editor_revision; @@ -2319,6 +2321,7 @@ impl AppStore { .unwrap_or_default(); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); // editorRevision은 이 트랜잭션만 관리 scratch.editor_revision = current_store.editor_revision; @@ -3496,6 +3499,7 @@ fn prepare_editor_patch_transition( candidate.apply_to_store(&mut scratch); crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); + crate::state::migration::normalize_sprite_triggers(&mut scratch); candidate = EditorDocumentV1::from_store(&scratch); validate_paired_update( @@ -3892,6 +3896,12 @@ fn collect_local_image_paths(app_data_dir: &Path, data: &AppStoreData) -> AssetR paths.collect(app_data_dir, position.active_image.as_ref()); paths.collect(app_data_dir, position.inactive_image.as_ref()); } + for sprite in data.sprite_positions.values().flatten() { + paths.collect(app_data_dir, sprite.base_image.as_ref()); + for pose in &sprite.poses { + paths.collect(app_data_dir, pose.image_override.as_ref()); + } + } collect_plugin_managed_asset_paths(app_data_dir, data, "images", &mut paths); @@ -4847,9 +4857,10 @@ mod tests { GraphType, JsPlugin, KeyCounters, KeyPosition, KeySlot, KnobPosition, LayerGroupDef, NoteColor, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, - SavedPluginInstance, SettingsPatchInput, SlotMatch, SoundLibraryEntry, SoundSource, - StatPosition, StatType, TabCss, TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, - EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + ReactiveSpritePosition, SavedPluginInstance, SettingsPatchInput, SlotMatch, + SoundLibraryEntry, SoundSource, SpritePose, StatPosition, StatType, TabCss, + TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, + EDITOR_SCHEMA_VERSION, }, services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, state::{ @@ -5421,6 +5432,7 @@ mod tests { EditorField::StatPositions, EditorField::GraphPositions, EditorField::KnobPositions, + EditorField::SpritePositions, EditorField::LayerGroups, ], PluginInstancesResetScope::Mode("4key".to_string()), @@ -10957,6 +10969,77 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn reactive_sprite_editor_commit_patch_round_trips_without_field_loss() { + let dir = test_directory("reactive-sprite-editor-commit-round-trip-test"); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let sprite = ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some("https://example.com/base.png".to_string()), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![ + "00000000-0000-4000-8000-000000000001".to_string(), + "00000000-0000-4000-8000-000000000002".to_string(), + ], + image_override: Some("https://example.com/pose.png".to_string()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let sprite_positions = + crate::models::SpritePositions::from([("4key".to_string(), vec![sprite.clone()])]); + + let committed = store + .commit_editor_document(editor_request( + 0, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(sprite_positions.clone()), + ..EditorPatchV1::default() + }, + )) + .unwrap(); + + assert_eq!( + committed.result.changed_fields, + [EditorField::SpritePositions] + ); + assert_eq!(committed.document.sprite_positions, sprite_positions); + assert_eq!( + store.editor_get().document.sprite_positions["4key"], + [sprite] + ); + + let mut duplicate_trigger_sets = committed.document.sprite_positions.clone(); + let mut duplicate_pose = duplicate_trigger_sets["4key"][0].poses[0].clone(); + duplicate_pose.pose_id = uuid::Uuid::new_v4().to_string(); + duplicate_pose.triggers.reverse(); + duplicate_trigger_sets.get_mut("4key").unwrap()[0] + .poses + .push(duplicate_pose); + let error = store + .commit_editor_document(editor_request( + committed.result.revision, + uuid::Uuid::new_v4().to_string(), + EditorPatchV1 { + schema_version: EDITOR_COMMIT_SCHEMA_VERSION_V2, + sprite_positions: Some(duplicate_trigger_sets), + ..EditorPatchV1::default() + }, + )) + .unwrap_err(); + assert_eq!( + error.details.unwrap().validation_code.as_deref(), + Some("DUPLICATE_SPRITE_POSE_TRIGGERS") + ); + + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn common_option_batches_replay_and_round_trip_all_native_types() { let dir = test_directory("editor-inline-style-history-test"); @@ -21331,6 +21414,62 @@ mod tests { } } + #[test] + fn reactive_sprite_images_survive_explicit_orphan_sweep() { + let dir = test_directory("reactive-sprite-image-sweep-test"); + let images = dir.join("images"); + std::fs::create_dir_all(&images).unwrap(); + let base_image = images.join("sprite-base.png"); + let first_pose_image = images.join("sprite-pose-first.png"); + let second_pose_image = images.join("sprite-pose-second.png"); + let orphan_image = images.join("orphan.png"); + for path in [ + &base_image, + &first_pose_image, + &second_pose_image, + &orphan_image, + ] { + std::fs::write(path, b"image").unwrap(); + } + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + legacy_editor_commit(&store, &[EditorField::SpritePositions], |data| { + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some(base_image.to_string_lossy().into_owned()), + poses: vec![ + SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: Some(first_pose_image.to_string_lossy().into_owned()), + ..SpritePose::default() + }, + SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: Some(second_pose_image.to_string_lossy().into_owned()), + ..SpritePose::default() + }, + ], + ..ReactiveSpritePosition::default() + }], + ); + }) + .unwrap(); + + store.cleanup_orphan_assets_now().unwrap(); + + assert!(base_image.exists()); + assert!(first_pose_image.exists()); + assert!(second_pose_image.exists()); + assert!(!orphan_image.exists()); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn plugin_managed_assets_remain_until_plugin_data_releases_them() { let dir = test_directory("plugin-managed-assets-sweep-test"); From 26d195c259a08906ca664e35c4430f572d6079a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 30 Aug 2026 22:04:09 +0900 Subject: [PATCH 002/125] =?UTF-8?q?feat:=20=EB=B0=98=EC=9D=91=ED=98=95=20?= =?UTF-8?q?=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EA=B3=BC=20=EC=97=90=EB=94=94=ED=84=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B3=84=EC=95=BD=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/editor/model/elementIdMap.ts | 7 +- src/renderer/stores/data/useSpriteStore.ts | 18 ++ src/renderer/utils/sprite/poseIdentity.ts | 8 + .../utils/sprite/poseResolver.test.ts | 278 ++++++++++++++++++ src/renderer/utils/sprite/poseResolver.ts | 136 +++++++++ .../utils/sprite/spriteKeyBinding.test.ts | 70 +++++ src/renderer/utils/sprite/spriteKeyBinding.ts | 21 ++ src/types/app.ts | 9 +- src/types/editor.ts | 77 ++++- src/types/key/sprites.ts | 156 ++++++++++ src/types/preview.ts | 3 +- tests/fixtures/editor-ops-parity.json | 144 ++++++--- 12 files changed, 866 insertions(+), 61 deletions(-) create mode 100644 src/renderer/stores/data/useSpriteStore.ts create mode 100644 src/renderer/utils/sprite/poseIdentity.ts create mode 100644 src/renderer/utils/sprite/poseResolver.test.ts create mode 100644 src/renderer/utils/sprite/poseResolver.ts create mode 100644 src/renderer/utils/sprite/spriteKeyBinding.test.ts create mode 100644 src/renderer/utils/sprite/spriteKeyBinding.ts create mode 100644 src/types/key/sprites.ts diff --git a/src/renderer/editor/model/elementIdMap.ts b/src/renderer/editor/model/elementIdMap.ts index 54c3807ea..f988e7ae5 100644 --- a/src/renderer/editor/model/elementIdMap.ts +++ b/src/renderer/editor/model/elementIdMap.ts @@ -1,11 +1,12 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; -export type NativeElementType = 'key' | 'stat' | 'graph' | 'knob'; +export type NativeElementType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; export interface ElementLocator { type: NativeElementType; @@ -17,7 +18,8 @@ type CanonicalPosition = | CanonicalEditorDocumentV1['keyPositions'][string][number] | CanonicalEditorDocumentV1['statPositions'][string][number] | CanonicalEditorDocumentV1['graphPositions'][string][number] - | CanonicalEditorDocumentV1['knobPositions'][string][number]; + | CanonicalEditorDocumentV1['knobPositions'][string][number] + | CanonicalEditorDocumentV1['spritePositions'][string][number]; type PositionsRecord = Record | undefined; // 권위 컬렉션만 읽는다. 키의 렌더 positions는 canonical + 프리뷰 합성이라 @@ -27,6 +29,7 @@ const authorityRecords = (): Record => ({ stat: useStatItemStore.getState().positions, graph: useGraphItemStore.getState().positions, knob: useKnobItemStore.getState().positions, + sprite: useSpriteStore.getState().positions, }); interface CacheEntry { diff --git a/src/renderer/stores/data/useSpriteStore.ts b/src/renderer/stores/data/useSpriteStore.ts new file mode 100644 index 000000000..0a65d9571 --- /dev/null +++ b/src/renderer/stores/data/useSpriteStore.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand'; +import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; + +type CanonicalSpritePositions = CanonicalEditorDocumentV1['spritePositions']; + +interface SpriteStoreState { + positions: CanonicalSpritePositions; + isLocalUpdateInProgress: boolean; + setPositions: (positions: CanonicalSpritePositions) => void; + setLocalUpdateInProgress: (value: boolean) => void; +} + +export const useSpriteStore = create((set) => ({ + positions: {}, + isLocalUpdateInProgress: false, + setPositions: (positions) => set({ positions }), + setLocalUpdateInProgress: (value) => set({ isLocalUpdateInProgress: value }), +})); diff --git a/src/renderer/utils/sprite/poseIdentity.ts b/src/renderer/utils/sprite/poseIdentity.ts new file mode 100644 index 000000000..b18f28e69 --- /dev/null +++ b/src/renderer/utils/sprite/poseIdentity.ts @@ -0,0 +1,8 @@ +import type { SpritePose } from '@src/types/key/sprites'; + +// 복제·붙여넣기 사본의 poseId 재발급. 원본과 poseId를 공유하면 백엔드가 +// 중복 poseId 커밋을 거부한다. 트리거·변환·이미지는 그대로 유지 +export const reissueSpritePoseIds = ( + poses: readonly SpritePose[], +): SpritePose[] => + poses.map((pose) => ({ ...pose, poseId: crypto.randomUUID() })); diff --git a/src/renderer/utils/sprite/poseResolver.test.ts b/src/renderer/utils/sprite/poseResolver.test.ts new file mode 100644 index 000000000..9959a46e1 --- /dev/null +++ b/src/renderer/utils/sprite/poseResolver.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; + +import type { + ReactiveSpritePosition, + SpritePose, + SpriteTransform, +} from '@src/types/key/sprites'; + +import { resolveSpriteTarget } from './poseResolver'; + +const makeTransform = ( + overrides: Partial = {}, +): SpriteTransform => ({ + x: 0, + y: 0, + rotation: 0, + scale: 1, + ...overrides, +}); + +const makePose = ( + poseId: string, + triggers: string[], + transform: Partial = {}, + imageOverride: string | null = null, +): SpritePose => ({ + poseId, + triggers, + matchMode: 'exact', + transform: makeTransform(transform), + imageOverride, +}); + +const makeSprite = ( + poses: SpritePose[], + overrides: Partial = {}, +): ReactiveSpritePosition => ({ + dx: 0, + dy: 0, + width: 200, + height: 120, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: 'base.png', + imageFit: null, + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: makeTransform(), + poses, + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'linear', + ...overrides, +}); + +// 진리표 기준 스프라이트: 키 요소 kA, kS, kD와 단일 자세 3개 +const poseA = makePose('poseA', ['kA'], { x: 10, y: -30, rotation: -30 }); +const poseS = makePose('poseS', ['kS'], { + x: 20, + y: 0, + rotation: 0, + scale: 2, +}); +const poseD = makePose('poseD', ['kD'], { + x: 60, + y: 30, + rotation: 30, + scale: 3, +}); + +const baseSprite = makeSprite([poseA, poseS, poseD]); + +const pressed = (...ids: string[]): ReadonlySet => new Set(ids); + +describe('resolveSpriteTarget 진리표', () => { + it('아무 키도 안 눌리면 idleTransform 참조와 baseImage를 반환한다', () => { + const result = resolveSpriteTarget(baseSprite, pressed()); + + expect(result.transform).toBe(baseSprite.idleTransform); + expect(result.imageSrc).toBe('base.png'); + }); + + it('kA만 눌리면 poseA의 transform 참조를 그대로 반환한다', () => { + const result = resolveSpriteTarget(baseSprite, pressed('kA')); + + expect(result.transform).toBe(poseA.transform); + expect(result.imageSrc).toBe('base.png'); + }); + + it('kA와 kS가 눌리면 두 자세의 균등 평균을 낸다', () => { + const result = resolveSpriteTarget(baseSprite, pressed('kA', 'kS')); + + expect(result.transform.x).toBeCloseTo(15, 10); + expect(result.transform.y).toBeCloseTo(-15, 10); + expect(result.transform.scale).toBeCloseTo(1.5, 10); + // -30과 0의 원형 평균은 -15 + expect(result.transform.rotation).toBeCloseTo(-15, 10); + }); + + it('kA, kS, kD가 눌리면 세 자세의 균등 평균을 낸다', () => { + const result = resolveSpriteTarget(baseSprite, pressed('kA', 'kS', 'kD')); + + expect(result.transform.x).toBeCloseTo(30, 10); + expect(result.transform.y).toBeCloseTo(0, 10); + expect(result.transform.scale).toBeCloseTo(2, 10); + // -30, 0, 30의 원형 평균은 0 + expect(result.transform.rotation).toBeCloseTo(0, 10); + }); + + it('triggers 집합이 정확히 일치하는 조합 자세가 있으면 그대로 쓴다', () => { + const comboAS = makePose('comboAS', ['kA', 'kS'], { x: 999, rotation: 45 }); + const sprite = makeSprite([poseA, poseS, poseD, comboAS]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS')); + + expect(result.transform).toBe(comboAS.transform); + expect(result.imageSrc).toBe('base.png'); + }); + + it('정확 일치가 실패하면 조합 자세를 빼고 단일 자세만 평균한다', () => { + const comboAS = makePose('comboAS', ['kA', 'kS'], { x: 999, y: 999 }); + const sprite = makeSprite([poseA, poseS, poseD, comboAS]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS', 'kD')); + + // comboAS(x 999)가 섞이지 않은 세 단일 자세의 평균 + expect(result.transform.x).toBeCloseTo(30, 10); + expect(result.transform.y).toBeCloseTo(0, 10); + expect(result.transform.scale).toBeCloseTo(2, 10); + expect(result.transform.rotation).toBeCloseTo(0, 10); + }); + + it('triggers 저장 순서가 달라도 집합으로 비교해 일치시킨다', () => { + const comboSA = makePose('comboSA', ['kS', 'kA'], { x: 77 }); + const sprite = makeSprite([poseA, poseS, poseD, comboSA]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS')); + + expect(result.transform).toBe(comboSA.transform); + }); + + it('triggers의 중복 항목은 무시하고 집합으로 비교한다', () => { + const comboDup = makePose('comboDup', ['kA', 'kA', 'kS'], { x: 55 }); + const sprite = makeSprite([poseA, poseS, poseD, comboDup]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS')); + + expect(result.transform).toBe(comboDup.transform); + }); + + it('rotation +170과 -170의 평균은 0이 아니라 +180이다', () => { + const sprite = makeSprite([ + makePose('poseA', ['kA'], { rotation: 170 }), + makePose('poseS', ['kS'], { rotation: -170 }), + ]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS')); + + // atan2(+0, 음수)는 +180, 부호를 양수로 고정한다 + expect(result.transform.rotation).toBeCloseTo(180, 10); + expect(result.transform.rotation).toBeLessThanOrEqual(180); + expect(result.transform.rotation).toBeGreaterThanOrEqual(-180); + }); + + it('rotation -80과 +80의 평균은 0이다', () => { + const sprite = makeSprite([ + makePose('poseA', ['kA'], { rotation: -80 }), + makePose('poseS', ['kS'], { rotation: 80 }), + ]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS')); + + expect(result.transform.rotation).toBeCloseTo(0, 10); + }); + + it('정반대 각 +90과 -90은 poseId 사전순 첫 자세의 rotation으로 폴백한다', () => { + // 평균 벡터 길이가 1e-16 수준이라 atan2 방향이 무의미한 경우 + const sprite = makeSprite([ + makePose('poseA', ['kA'], { rotation: 90 }), + makePose('poseS', ['kS'], { rotation: -90 }), + ]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS')); + + expect(result.transform.rotation).toBe(90); + }); + + it('담당 밖 키만 눌리면 idle을 반환한다', () => { + const result = resolveSpriteTarget(baseSprite, pressed('kZ', 'kX')); + + expect(result.transform).toBe(baseSprite.idleTransform); + expect(result.imageSrc).toBe('base.png'); + }); + + it('죽은 키 id가 섞여도 나머지는 정상 해석한다', () => { + const result = resolveSpriteTarget(baseSprite, pressed('kA', 'ghost')); + + expect(result.transform).toBe(poseA.transform); + }); + + it('평균 imageSrc는 poseId 사전순 첫 imageOverride를 쓴다', () => { + const sprite = makeSprite([ + makePose('poseA', ['kA'], {}, null), + makePose('poseD', ['kD'], {}, 'pose-d.png'), + makePose('poseS', ['kS'], {}, 'pose-s.png'), + ]); + + // poseA는 override 없음, 사전순 다음인 poseD의 override 선택 + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS', 'kD')); + + expect(result.imageSrc).toBe('pose-d.png'); + }); + + it('평균 참여 자세에 imageOverride가 하나도 없으면 baseImage를 쓴다', () => { + const result = resolveSpriteTarget(baseSprite, pressed('kA', 'kS')); + + expect(result.imageSrc).toBe('base.png'); + }); + + it('정확 일치 자세의 imageOverride ?? baseImage 규칙을 지킨다', () => { + const withOverride = makePose('poseA', ['kA'], {}, 'pose-a.png'); + const sprite = makeSprite([withOverride, poseS]); + + expect(resolveSpriteTarget(sprite, pressed('kA')).imageSrc).toBe( + 'pose-a.png', + ); + expect(resolveSpriteTarget(sprite, pressed('kS')).imageSrc).toBe( + 'base.png', + ); + }); + + it('Set 삽입 순서가 달라도 같은 집합이면 결과가 동일하다', () => { + const forward = resolveSpriteTarget( + baseSprite, + new Set(['kA', 'kS', 'kD']), + ); + const backward = resolveSpriteTarget( + baseSprite, + new Set(['kD', 'kS', 'kA']), + ); + + expect(backward).toEqual(forward); + }); + + it('활성 키가 조합 자세에만 속하면 평균 대상이 없어 idle로 처리한다', () => { + const comboOnly = makeSprite([makePose('comboAS', ['kA', 'kS'], { x: 5 })]); + + const result = resolveSpriteTarget(comboOnly, pressed('kA')); + + expect(result.transform).toBe(comboOnly.idleTransform); + expect(result.imageSrc).toBe('base.png'); + }); + + it('중복 trigger를 가진 자세도 집합 크기 1이면 단일 자세로 평균에 참여한다', () => { + const dupSingle = makePose('poseA', ['kA', 'kA'], { x: 10, y: -30 }); + const sprite = makeSprite([dupSingle, poseS]); + + const result = resolveSpriteTarget(sprite, pressed('kA', 'kS')); + + expect(result.transform.x).toBeCloseTo(15, 10); + expect(result.transform.y).toBeCloseTo(-15, 10); + }); + + it('입력 스프라이트와 눌림 집합을 변경하지 않는다', () => { + const sprite = makeSprite([poseD, poseA, poseS]); + const originalOrder = sprite.poses.map((pose) => pose.poseId); + const keys = new Set(['kA', 'kS']); + + resolveSpriteTarget(sprite, keys); + + expect(sprite.poses.map((pose) => pose.poseId)).toEqual(originalOrder); + expect([...keys]).toEqual(['kA', 'kS']); + }); +}); diff --git a/src/renderer/utils/sprite/poseResolver.ts b/src/renderer/utils/sprite/poseResolver.ts new file mode 100644 index 000000000..81289a687 --- /dev/null +++ b/src/renderer/utils/sprite/poseResolver.ts @@ -0,0 +1,136 @@ +import type { + ReactiveSpritePosition, + SpritePose, + SpriteTransform, +} from '@src/types/key/sprites'; + +// 평균 벡터 길이가 이보다 작으면 방향이 정의되지 않은 것으로 본다 (정반대 각 조합) +const DEGENERATE_MEAN_VECTOR_EPSILON = 1e-9; + +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; + +export interface SpriteTargetResolution { + transform: SpriteTransform; + imageSrc: string | null; +} + +// poseId 코드포인트 사전순, 로케일 무관 고정 순서 +const byPoseId = (a: SpritePose, b: SpritePose): number => { + if (a.poseId < b.poseId) return -1; + if (a.poseId > b.poseId) return 1; + return 0; +}; + +// atan2를 도 단위로 바꿀 때 부동소수점 오차가 ±180을 넘지 않게 고정 +const clampRotation = (deg: number): number => + Math.min(180, Math.max(-180, deg)); + +const setEquals = (a: ReadonlySet, b: ReadonlySet): boolean => { + if (a.size !== b.size) return false; + for (const value of a) { + if (!b.has(value)) return false; + } + return true; +}; + +// 눌린 키 집합만 읽는 순수 해석. 눌린 순서, 시각, 이전 상태에 의존하지 않는다 +export const resolveSpriteTarget = ( + sprite: ReactiveSpritePosition, + pressedKeyElementIds: ReadonlySet, +): SpriteTargetResolution => { + const sortedPoses = [...sprite.poses].sort(byPoseId); + + // 1단계: 담당 키 중 지금 눌린 것, 담당 밖 키와 죽은 키 id는 여기서 걸러진다 + const active = new Set(); + for (const pose of sortedPoses) { + for (const trigger of pose.triggers) { + if (pressedKeyElementIds.has(trigger)) active.add(trigger); + } + } + + // 2단계: 활성 키 없음, 저장된 참조 그대로 반환해 identity 보존 + if (active.size === 0) { + return { transform: sprite.idleTransform, imageSrc: sprite.baseImage }; + } + + // 3단계: triggers 집합이 active와 정확히 같은 pose (순서와 중복 무시) + for (const pose of sortedPoses) { + if (setEquals(new Set(pose.triggers), active)) { + return { + transform: pose.transform, + imageSrc: pose.imageOverride ?? sprite.baseImage, + }; + } + } + + // 4단계: 활성 키별 단일 pose 균등 평균, 조합 pose는 참여하지 않는다 + // 단일 여부는 중복을 무시한 집합 크기 기준, 키당 pose는 poseId 사전순 첫 하나 + const singles: SpritePose[] = []; + const coveredKeys = new Set(); + for (const pose of sortedPoses) { + const triggerSet = new Set(pose.triggers); + if (triggerSet.size !== 1) continue; + const [key] = triggerSet; + if (!active.has(key) || coveredKeys.has(key)) continue; + coveredKeys.add(key); + singles.push(pose); + } + + // 활성 키가 전부 조합 pose에만 속하면 평균 대상이 없다, idle과 동일 처리 + if (singles.length === 0) { + return { transform: sprite.idleTransform, imageSrc: sprite.baseImage }; + } + + // 원소 하나의 평균은 자기 자신, 참조 identity 보존 + if (singles.length === 1) { + const [pose] = singles; + return { + transform: pose.transform, + imageSrc: pose.imageOverride ?? sprite.baseImage, + }; + } + + let sumX = 0; + let sumY = 0; + let sumScale = 0; + let sumSin = 0; + let sumCos = 0; + for (const pose of singles) { + sumX += pose.transform.x; + sumY += pose.transform.y; + sumScale += pose.transform.scale; + const rad = pose.transform.rotation * DEG_TO_RAD; + sumSin += Math.sin(rad); + sumCos += Math.cos(rad); + } + + const count = singles.length; + + // rotation은 산술 평균 금지, +170과 -170의 정답은 0이 아니라 180 + // 정반대 각끼리는 평균 벡터가 0이라 방향이 불안정, poseId 사전순 첫 자세로 폴백 + const meanVectorLength = Math.hypot(sumSin, sumCos) / count; + const rotation = + meanVectorLength < DEGENERATE_MEAN_VECTOR_EPSILON + ? singles[0].transform.rotation + : clampRotation(Math.atan2(sumSin, sumCos) * RAD_TO_DEG); + + // poseId 사전순 첫 imageOverride, 없으면 baseImage + let imageSrc = sprite.baseImage; + for (const pose of singles) { + if (pose.imageOverride != null) { + imageSrc = pose.imageOverride; + break; + } + } + + return { + transform: { + x: sumX / count, + y: sumY / count, + rotation, + scale: sumScale / count, + }, + imageSrc, + }; +}; diff --git a/src/renderer/utils/sprite/spriteKeyBinding.test.ts b/src/renderer/utils/sprite/spriteKeyBinding.test.ts new file mode 100644 index 000000000..2dd4405e5 --- /dev/null +++ b/src/renderer/utils/sprite/spriteKeyBinding.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import type { KeySlot } from '@src/types/key/keys'; + +import { buildSpriteKeyCanonicalMap } from './spriteKeyBinding'; + +describe('buildSpriteKeyCanonicalMap', () => { + it('단일 키 슬롯을 id -> canonical로 매핑한다', () => { + const keys: KeySlot[] = ['KeyA', 'KeyB']; + const positions = [{ id: 'el-1' }, { id: 'el-2' }]; + + const map = buildSpriteKeyCanonicalMap(keys, positions); + + expect(map.size).toBe(2); + expect(map.get('el-1')).toBe('KeyA'); + expect(map.get('el-2')).toBe('KeyB'); + }); + + it('멀티 슬롯은 all/any 구분자로 합성한 canonical을 쓴다', () => { + const keys: KeySlot[] = [ + { keys: ['LControl', 'KeyZ'], match: 'all' }, + { keys: ['KeyZ', 'KeyB'], match: 'any' }, + ]; + const positions = [{ id: 'el-all' }, { id: 'el-any' }]; + + const map = buildSpriteKeyCanonicalMap(keys, positions); + + expect(map.get('el-all')).toBe('LControl+KeyZ'); + expect(map.get('el-any')).toBe('KeyZ|KeyB'); + }); + + it('빈 슬롯은 제외한다', () => { + const keys: KeySlot[] = ['', 'KeyC']; + const positions = [{ id: 'el-empty' }, { id: 'el-c' }]; + + const map = buildSpriteKeyCanonicalMap(keys, positions); + + expect(map.size).toBe(1); + expect(map.has('el-empty')).toBe(false); + expect(map.get('el-c')).toBe('KeyC'); + }); + + it('id 없는 위치는 제외한다', () => { + const keys: KeySlot[] = ['KeyA', 'KeyB', 'KeyC']; + const positions = [{ id: null }, {}, { id: 'el-c' }]; + + const map = buildSpriteKeyCanonicalMap(keys, positions); + + expect(map.size).toBe(1); + expect(map.get('el-c')).toBe('KeyC'); + }); + + it('두 배열 길이가 달라도 짧은 쪽까지만 짝을 만든다', () => { + const keysLonger: KeySlot[] = ['KeyA', 'KeyB', 'KeyC']; + const positionsShorter = [{ id: 'el-1' }]; + expect(buildSpriteKeyCanonicalMap(keysLonger, positionsShorter).size).toBe( + 1, + ); + + const keysShorter: KeySlot[] = ['KeyA']; + const positionsLonger = [{ id: 'el-1' }, { id: 'el-2' }]; + const map = buildSpriteKeyCanonicalMap(keysShorter, positionsLonger); + expect(map.size).toBe(1); + expect(map.has('el-2')).toBe(false); + }); + + it('입력이 비면 빈 맵을 돌려준다', () => { + expect(buildSpriteKeyCanonicalMap([], []).size).toBe(0); + }); +}); diff --git a/src/renderer/utils/sprite/spriteKeyBinding.ts b/src/renderer/utils/sprite/spriteKeyBinding.ts new file mode 100644 index 000000000..b03474746 --- /dev/null +++ b/src/renderer/utils/sprite/spriteKeyBinding.ts @@ -0,0 +1,21 @@ +import type { KeySlot } from '@src/types/key/keys'; + +import { slotCanonical } from '@utils/keySlot'; + +// 스프라이트 트리거는 키 요소 id(레인 결합), 눌림 시그널은 canonical 키 문자열. +// keys[i]와 keyPositions[i]는 인덱스 결합이라 같은 i끼리 짝을 만든다 +export const buildSpriteKeyCanonicalMap = ( + keys: readonly KeySlot[], + keyPositions: readonly { id?: string | null }[], +): ReadonlyMap => { + const map = new Map(); + const length = Math.min(keys.length, keyPositions.length); + for (let i = 0; i < length; i++) { + const id = keyPositions[i]?.id; + if (!id) continue; + const canonical = slotCanonical(keys[i]); + if (canonical === '') continue; + map.set(id, canonical); + } + return map; +}; diff --git a/src/types/app.ts b/src/types/app.ts index 74d0902f9..4286199c3 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -8,6 +8,7 @@ import { import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; +import type { SpritePositions } from '@src/types/key/sprites'; import type { DefaultsPayload } from '@src/renderer/defaults'; import type { LayerGroups } from '@src/types/layerGroups'; import type { TabNoteOverrides } from '@src/types/settings/noteSettings'; @@ -22,6 +23,7 @@ export interface BootstrapPayload { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; customTabs: CustomTab[]; selectedKeyType: string; currentMode: string; @@ -44,10 +46,15 @@ export interface BootstrapPayload { export interface CanonicalBootstrapPayload extends Omit< BootstrapPayload, - 'positions' | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'positions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'spritePositions' > { positions: CanonicalEditorDocumentV1['keyPositions']; statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; } diff --git a/src/types/editor.ts b/src/types/editor.ts index b6e757acb..b82678fad 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -9,6 +9,11 @@ import { type KeySlot, } from '@src/types/key/keys'; import type { KnobItemPosition, KnobItemPositions } from '@src/types/key/knobs'; +import { + reactiveSpritePositionSchema, + type ReactiveSpritePosition, + type SpritePositions, +} from '@src/types/key/sprites'; import { STAT_ITEM_TYPES, type StatItemPosition, @@ -54,6 +59,7 @@ export const EDITOR_FIELDS = [ 'statPositions', 'graphPositions', 'knobPositions', + 'spritePositions', 'layerGroups', ] as const; @@ -66,6 +72,7 @@ export interface EditorDocumentV1 { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; layerGroups: LayerGroups; } @@ -73,16 +80,24 @@ export type CanonicalKeyPosition = KeyPosition & { id: string }; export type CanonicalStatItemPosition = StatItemPosition & { id: string }; export type CanonicalGraphItemPosition = GraphItemPosition & { id: string }; export type CanonicalKnobItemPosition = KnobItemPosition & { id: string }; +export type CanonicalReactiveSpritePosition = ReactiveSpritePosition & { + id: string; +}; export interface CanonicalEditorDocumentV1 extends Omit< EditorDocumentV1, - 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'spritePositions' > { keyPositions: Record; statPositions: Record; graphPositions: Record; knobPositions: Record; + spritePositions: Record; } export type EditorPatchV1 = { @@ -115,7 +130,7 @@ export interface EditorPatchCommitRequest extends EditorCommitRequestBase { ops?: never; } -export type EditorElementTypeV1 = 'key' | 'stat' | 'graph' | 'knob'; +export type EditorElementTypeV1 = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; export interface EditorBoundsV1 { dx: number; @@ -141,7 +156,8 @@ export type EditorFrozenElementV1 = | { elementType: 'key'; slot: KeySlot; position: CanonicalKeyPosition } | { elementType: 'stat'; position: CanonicalStatItemPosition } | { elementType: 'graph'; position: CanonicalGraphItemPosition } - | { elementType: 'knob'; position: CanonicalKnobItemPosition }; + | { elementType: 'knob'; position: CanonicalKnobItemPosition } + | { elementType: 'sprite'; position: CanonicalReactiveSpritePosition }; export interface EditorFrozenZUpdateV1 { elementType: EditorElementTypeV1; @@ -1102,6 +1118,20 @@ const assertKnobPosition: ( } }; +// 스프라이트는 KeyPosition 형태가 아니라 전용 스키마로 검증한다. +// nullable 필드는 스키마가 직접 허용하므로 별도 정규화가 필요 없다 +const assertSpritePosition: ( + value: unknown, + label: string, +) => asserts value is ReactiveSpritePosition = ( + value: unknown, + label: string, +) => { + if (!reactiveSpritePositionSchema.safeParse(value).success) { + throw new EditorProtocolError(`${label} is not a valid sprite position`); + } +}; + const assertLayerGroups = (value: unknown, label: string): void => { assertModeRecord(value, label, (item, itemLabel) => { if ( @@ -1133,6 +1163,8 @@ const assertEditorCollections = ( assertModeRecord(collection, collectionLabel, assertGraphPosition), knobPositions: (collection, collectionLabel) => assertModeRecord(collection, collectionLabel, assertKnobPosition), + spritePositions: (collection, collectionLabel) => + assertModeRecord(collection, collectionLabel, assertSpritePosition), layerGroups: assertLayerGroups, }; @@ -1199,6 +1231,7 @@ const assertLayerGroupReferences = ( 'statPositions', 'graphPositions', 'knobPositions', + 'spritePositions', ] as const ).forEach((field) => { Object.entries(value[field] as Record).forEach( @@ -1221,6 +1254,7 @@ const assertLayerGroupReferences = ( }); }; +// 그라데이션 형제 필드 정규화 대상. 스프라이트는 그라데이션 필드가 없어 제외 const POSITION_COLLECTION_FIELDS = [ 'keyPositions', 'statPositions', @@ -1297,6 +1331,7 @@ export function assertCanonicalEditorDocument( ['statPositions', document.statPositions], ['graphPositions', document.graphPositions], ['knobPositions', document.knobPositions], + ['spritePositions', document.spritePositions], ] as const; for (const [field, collection] of collections) { for (const [mode, positions] of Object.entries(collection)) { @@ -1455,6 +1490,23 @@ function assertEditorFrozenElement( assertFrozenPositionZIndex(value.position, `${label}.position`); return; } + if (value.elementType === 'sprite') { + assertSpritePosition(value.position, `${label}.position`); + const position = value.position as Record; + if (!isNativeElementId(position.id)) { + throw new EditorProtocolError(`${label}.position.id is invalid`); + } + // 스프라이트 zIndex는 null 허용 - 정수 범위 검사는 값이 있을 때만 + if ( + position.zIndex !== null && + (!Number.isSafeInteger(position.zIndex) || + (position.zIndex as number) < -2_147_483_648 || + (position.zIndex as number) > 2_147_483_647) + ) { + throw new EditorProtocolError(`${label}.position.zIndex is invalid`); + } + return; + } throw new EditorProtocolError(`${label}.elementType is invalid`); } @@ -1813,7 +1865,9 @@ export function assertEditorOpsV1( if (op.kind === 'setBounds') { assertExactKeys(op, ['kind', 'elementType', 'id', 'bounds'], opLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes(op.elementType as string) || + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( + op.elementType as string, + ) || !isNativeElementId(op.id) ) { throw new EditorProtocolError(`${opLabel} target is invalid`); @@ -1825,7 +1879,9 @@ export function assertEditorOpsV1( if (op.kind === 'deleteElement') { assertExactKeys(op, ['kind', 'elementType', 'id'], opLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes(op.elementType as string) || + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( + op.elementType as string, + ) || !isNativeElementId(op.id) ) { throw new EditorProtocolError(`${opLabel} target is invalid`); @@ -1836,7 +1892,9 @@ export function assertEditorOpsV1( if (op.kind === 'patchElement') { assertExactKeys(op, ['kind', 'elementType', 'id', 'patch'], opLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes(op.elementType as string) || + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( + op.elementType as string, + ) || !isNativeElementId(op.id) || !isRecord(op.patch) ) { @@ -1875,7 +1933,7 @@ export function assertEditorOpsV1( } assertExactKeys(target, ['elementType', 'id'], targetLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes( + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( target.elementType as string, ) || typeof target.id !== 'string' || @@ -1981,7 +2039,7 @@ export function assertEditorOpsV1( targetLabel: string, ) => { if ( - !['key', 'stat', 'graph', 'knob'].includes( + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( target.elementType as string, ) || !isNativeElementId(target.id) @@ -2089,7 +2147,7 @@ export function assertEditorOpsV1( } assertExactKeys(update, ['elementType', 'id', 'zIndex'], updateLabel); if ( - !['key', 'stat', 'graph', 'knob'].includes( + !['key', 'stat', 'graph', 'knob', 'sprite'].includes( update.elementType as string, ) || !isNativeElementId(update.id) || @@ -2170,6 +2228,7 @@ export function assertEditorOpCommitResult( stat: 'statPositions', graph: 'graphPositions', knob: 'knobPositions', + sprite: 'spritePositions', }; const requiredFields = new Set(); const allowedFields = new Set(); diff --git a/src/types/key/sprites.ts b/src/types/key/sprites.ts new file mode 100644 index 000000000..1934ec832 --- /dev/null +++ b/src/types/key/sprites.ts @@ -0,0 +1,156 @@ +import { z } from 'zod'; + +// 반응형 스프라이트: 키 입력에 따라 자세가 바뀌는 이미지 레이어. +// 키 요소와 달리 키 매핑에 인덱스 결합하지 않고, 담당 키를 자세마다 직접 참조한다. +// 저장 구조는 Rust ReactiveSpritePosition과 동일 + +export const SPRITE_CONSTRAINTS = { + offset: { min: -2000, max: 2000 }, + rotation: { min: -180, max: 180 }, + scale: { min: 0.1, max: 10 }, + anchor: { min: 0, max: 1 }, + transitionMs: { min: 0, max: 1000 }, + maxPoses: 64, +} as const; + +export const spriteTransformSchema = z.object({ + x: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.offset.min) + .max(SPRITE_CONSTRAINTS.offset.max), + y: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.offset.min) + .max(SPRITE_CONSTRAINTS.offset.max), + rotation: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.rotation.min) + .max(SPRITE_CONSTRAINTS.rotation.max), + scale: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.scale.min) + .max(SPRITE_CONSTRAINTS.scale.max), +}); +export type SpriteTransform = z.infer; + +export const IDENTITY_SPRITE_TRANSFORM: SpriteTransform = Object.freeze({ + x: 0, + y: 0, + rotation: 0, + scale: 1, +}); + +// 백엔드 계약과 동일 3종. 키 이미지의 none은 스프라이트에 없다 +export const spriteImageFitSchema = z.union([ + z.literal('cover'), + z.literal('contain'), + z.literal('fill'), +]); +export type SpriteImageFit = z.infer; + +export const spriteRectSchema = z.object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite().positive(), + height: z.number().finite().positive(), +}); +export type SpriteRect = z.infer; + +// 원본 이미지 기준 정규화 좌표. CSS transform-origin으로 쓴다 +export const spriteAnchorSchema = z.object({ + x: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.anchor.min) + .max(SPRITE_CONSTRAINTS.anchor.max), + y: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.anchor.min) + .max(SPRITE_CONSTRAINTS.anchor.max), +}); +export type SpriteAnchor = z.infer; + +export const CENTER_SPRITE_ANCHOR: SpriteAnchor = Object.freeze({ + x: 0.5, + y: 0.5, +}); + +// 1차는 정확 일치만. 값을 저장해 두면 부분집합 오버라이드 확장이 마이그레이션 없이 열린다 +export const spriteMatchModeSchema = z.literal('exact'); +export type SpriteMatchMode = z.infer; + +// 1차는 누르고 있는 동안만. 단발 이펙트(onPress)는 나중 +export const spriteActivationSchema = z.literal('whileHeld'); +export type SpriteActivation = z.infer; + +export const spritePoseSchema = z.object({ + poseId: z.string().min(1), + // 키 요소 id 목록. 물리 키가 아니라 레인에 결합해서 키 매핑을 바꿔도 자리를 유지한다 + triggers: z.array(z.string().min(1)).min(1), + matchMode: spriteMatchModeSchema, + transform: spriteTransformSchema, + imageOverride: z.string().nullable(), +}); +export type SpritePose = z.infer; + +export const reactiveSpritePositionSchema = z.object({ + // 백엔드 발급 요소 id. 발급 전에는 없을 수 있고 canonical 검증이 필수화한다 + id: z.string().min(1).nullish(), + // 활동 영역. 손이 움직이는 전체 범위이고 창 크기 계산에 그대로 들어간다 + dx: z.number().finite(), + dy: z.number().finite(), + width: z.number().finite().positive(), + height: z.number().finite().positive(), + hidden: z.boolean(), + zIndex: z.number().finite().nullable(), + layerName: z.string().nullable(), + groupId: z.string().nullable(), + className: z.string().nullable(), + useInlineStyles: z.boolean().nullable(), + + baseImage: z.string().nullable(), + imageFit: spriteImageFitSchema.nullable(), + imageRect: spriteRectSchema, + pivot: spriteAnchorSchema, + + idleTransform: spriteTransformSchema, + poses: z.array(spritePoseSchema).max(SPRITE_CONSTRAINTS.maxPoses), + activation: spriteActivationSchema, + + transitionMs: z + .number() + .finite() + .min(SPRITE_CONSTRAINTS.transitionMs.min) + .max(SPRITE_CONSTRAINTS.transitionMs.max), + transitionEasing: z.string(), +}); +export type ReactiveSpritePosition = z.infer< + typeof reactiveSpritePositionSchema +>; + +export type SpritePositions = Record; + +export const DEFAULT_SPRITE_TRANSITION_MS = 90; +export const DEFAULT_SPRITE_TRANSITION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'; + +// 같은 담당 키 집합을 가진 자세가 둘이면 해석이 모호해진다. 저장 전에 걸러낸다 +export const findDuplicateTriggerPose = ( + poses: readonly SpritePose[], +): SpritePose | null => { + const seen = new Set(); + for (const pose of poses) { + // 집합 비교라 pose 안의 중복 트리거도 무시한다 + const key = [...new Set(pose.triggers)].sort().join(' '); + if (seen.has(key)) return pose; + seen.add(key); + } + return null; +}; + +export const spriteTransformToCss = (transform: SpriteTransform): string => + `translate(${transform.x}px, ${transform.y}px) rotate(${transform.rotation}deg) scale(${transform.scale})`; diff --git a/src/types/preview.ts b/src/types/preview.ts index 5bf6d308c..1d487a2cc 100644 --- a/src/types/preview.ts +++ b/src/types/preview.ts @@ -6,7 +6,8 @@ export type PreviewDomain = | 'keyPosition' | 'statPosition' | 'graphPosition' - | 'knobPosition'; + | 'knobPosition' + | 'spritePosition'; export type PreviewEnvelopeKind = 'patch' | 'cancel'; diff --git a/tests/fixtures/editor-ops-parity.json b/tests/fixtures/editor-ops-parity.json index f345209ea..0fff65553 100644 --- a/tests/fixtures/editor-ops-parity.json +++ b/tests/fixtures/editor-ops-parity.json @@ -25,7 +25,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -55,7 +56,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -81,7 +83,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -111,7 +114,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -177,7 +181,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -259,7 +264,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -343,7 +349,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -457,7 +464,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -494,7 +502,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -541,7 +550,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -666,7 +676,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -807,7 +818,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -858,7 +870,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -922,7 +935,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -983,7 +997,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1088,7 +1103,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1184,7 +1200,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1334,7 +1351,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1387,7 +1405,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1490,7 +1509,8 @@ } ] }, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1538,7 +1558,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1625,7 +1646,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1667,7 +1689,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1729,7 +1752,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1758,7 +1782,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -1834,7 +1859,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -1920,7 +1946,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2022,7 +2049,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -2070,7 +2098,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2129,7 +2158,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -2180,7 +2210,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2214,7 +2245,8 @@ "statPositions": { "4key": [] }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [] } + "layerGroups": { "4key": [] }, + "spritePositions": {} } }, { @@ -2240,7 +2272,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2337,7 +2370,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-new", "name": "New Group" }] } + "layerGroups": { "4key": [{ "id": "group-new", "name": "New Group" }] }, + "spritePositions": {} } }, { @@ -2387,7 +2421,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} }, "ops": [ { @@ -2455,7 +2490,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": {} + "layerGroups": {}, + "spritePositions": {} } }, { @@ -2512,7 +2548,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} }, "ops": [ { @@ -2604,7 +2641,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} } }, { @@ -2641,7 +2679,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2701,7 +2740,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [] } + "layerGroups": { "4key": [] }, + "spritePositions": {} } }, { @@ -2742,7 +2782,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2801,7 +2842,8 @@ }, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-new", "name": "Fresh" }] } + "layerGroups": { "4key": [{ "id": "group-new", "name": "Fresh" }] }, + "spritePositions": {} } }, { @@ -2858,7 +2900,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} }, "ops": [ { @@ -2925,7 +2968,8 @@ { "id": "group-a", "name": "Group A" }, { "id": "group-b", "name": "Group B" } ] - } + }, + "spritePositions": {} } }, { @@ -2952,7 +2996,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -2987,7 +3032,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [] } + "layerGroups": { "4key": [] }, + "spritePositions": {} } }, { @@ -3014,7 +3060,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Group A" }] }, + "spritePositions": {} }, "ops": [ { @@ -3045,7 +3092,8 @@ "statPositions": {}, "graphPositions": {}, "knobPositions": {}, - "layerGroups": { "4key": [{ "id": "group-a", "name": "Renamed" }] } + "layerGroups": { "4key": [{ "id": "group-a", "name": "Renamed" }] }, + "spritePositions": {} } } ] From 777ff557f26ca202f9ac3dd208e794c73ba6ca72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 30 Aug 2026 22:04:09 +0900 Subject: [PATCH 003/125] =?UTF-8?q?feat:=20=ED=8E=B8=EC=A7=91=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=C2=B7=EA=B7=B8=EB=A3=B9=C2=B7=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EA=B2=BD=EB=A1=9C=EC=97=90=20=EC=8A=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=ED=8E=B8=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/editorDocumentSelection.test.ts | 1 + src/renderer/api/modules/appApi.ts | 3 + .../api/modules/editorAdapters.test.ts | 18 +++ src/renderer/api/modules/editorApi.test.ts | 1 + src/renderer/components/main/Settings.tsx | 2 + .../editor/runtime/deleteFrozenSelection.ts | 17 +- .../editor/runtime/editGestureController.ts | 6 +- .../editor/runtime/editorCoordinator.test.ts | 2 + .../editor/runtime/editorCoordinator.ts | 7 + .../editorGestureDiscardIntegration.test.ts | 1 + .../runtime/editorStateCoordinator.test.ts | 1 + .../editor/runtime/editorStateCoordinator.ts | 9 ++ src/renderer/editor/runtime/elementIntent.ts | 21 ++- .../editor/runtime/elementOps.test.ts | 148 ++++++++++++++++++ src/renderer/editor/runtime/elementOps.ts | 81 ++++++++-- .../editor/runtime/layerZOrderIntent.test.ts | 3 + .../editor/runtime/layerZOrderIntent.ts | 3 + .../editor/runtime/mixedBatchGeometry.ts | 12 +- .../runtime/mixedCommitRegeneration.test.ts | 1 + .../editor/runtime/mixedElementGroups.test.ts | 101 +++++++++++- .../editor/runtime/mixedElementGroups.ts | 19 ++- .../mixedMoveSettlementIntegration.test.ts | 1 + .../paintGestureDiscardIntegration.test.ts | 1 + .../editor/runtime/pluginGroupMembers.test.ts | 1 + .../Grid/mixedDragSettlement.e2e.test.tsx | 1 + .../hooks/Grid/useGridSelection.test.tsx | 123 ++++++++++++++- src/renderer/hooks/Grid/useGridSelection.ts | 116 +++++++++++++- src/renderer/hooks/app/useAppBootstrap.ts | 5 + .../useGridSelectionStore.idReconcile.test.ts | 1 + .../stores/grid/useGridSelectionStore.ts | 10 ++ src/renderer/utils/grid/groupActions.ts | 10 +- .../utils/grid/groupSelection.test.ts | 1 + src/renderer/utils/grid/groupSelection.ts | 32 ++-- .../utils/layerGroupUtils.identity.test.ts | 1 + src/renderer/utils/layerGroupUtils.test.ts | 1 + src/renderer/utils/layerGroupUtils.ts | 96 ++++++++++-- 36 files changed, 794 insertions(+), 63 deletions(-) diff --git a/src/renderer/__tests__/editorDocumentSelection.test.ts b/src/renderer/__tests__/editorDocumentSelection.test.ts index 161944d9c..327d61238 100644 --- a/src/renderer/__tests__/editorDocumentSelection.test.ts +++ b/src/renderer/__tests__/editorDocumentSelection.test.ts @@ -34,6 +34,7 @@ const makeDocument = (): CanonicalEditorDocumentV1 => ({ statPositions: { '4key': [] }, graphPositions: { '4key': [] }, knobPositions: { '4key': [] }, + spritePositions: { '4key': [] }, layerGroups: { '4key': [] }, }); diff --git a/src/renderer/api/modules/appApi.ts b/src/renderer/api/modules/appApi.ts index 0d0805325..b952b97d2 100644 --- a/src/renderer/api/modules/appApi.ts +++ b/src/renderer/api/modules/appApi.ts @@ -56,6 +56,8 @@ export const cancelLifecycleEditorFlush = (handshakeId: string) => export const appApi = { bootstrap: async (): Promise => { const payload = await invoke('app_bootstrap'); + // 구버전 payload 호환: 스프라이트 미포함이면 빈 컬렉션 + payload.spritePositions ??= {}; assertCanonicalEditorDocument( { schemaVersion: 1, @@ -64,6 +66,7 @@ export const appApi = { statPositions: payload.statPositions, graphPositions: payload.graphPositions, knobPositions: payload.knobPositions, + spritePositions: payload.spritePositions, layerGroups: payload.layerGroups, }, 'app_bootstrap editor document', diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts index 8b94b33ec..f9c7e1700 100644 --- a/src/renderer/api/modules/editorAdapters.test.ts +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -14,6 +14,7 @@ import { graphItemsApi, knobItemsApi, layerGroupsApi, + spriteItemsApi, statItemsApi, } from './itemsApi'; @@ -27,6 +28,7 @@ const document: EditorDocumentV1 = { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: { '4key': [{ id: 'group-1', name: 'Group 1' }] }, }; @@ -81,6 +83,7 @@ describe('editor API compatibility adapters', () => { await expect(statItemsApi.updatePositions({})).resolves.toEqual({}); await expect(graphItemsApi.updatePositions({})).resolves.toEqual({}); await expect(knobItemsApi.updatePositions({})).resolves.toEqual({}); + await expect(spriteItemsApi.updatePositions({})).resolves.toEqual({}); await expect(layerGroupsApi.update(document.layerGroups)).resolves.toEqual( document.layerGroups, ); @@ -89,10 +92,25 @@ describe('editor API compatibility adapters', () => { [{ schemaVersion: 1, statPositions: {} }], [{ schemaVersion: 1, graphPositions: {} }], [{ schemaVersion: 1, knobPositions: {} }], + [{ schemaVersion: 1, spritePositions: {} }, undefined], [{ schemaVersion: 1, layerGroups: document.layerGroups }], ]); }); + it('sprite writer는 gestureId를 editor_commit 메타로 실어 보낸다', async () => { + await expect( + spriteItemsApi.updatePositions( + {}, + '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50', + ), + ).resolves.toEqual({}); + + expect(commitPatch).toHaveBeenCalledWith( + { schemaVersion: 1, spritePositions: {} }, + { gestureId: '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50' }, + ); + }); + it('preserves each legacy caller return value when writes are queued together', async () => { const first = { '4key': ['B'] }; const second = { '4key': ['C'] }; diff --git a/src/renderer/api/modules/editorApi.test.ts b/src/renderer/api/modules/editorApi.test.ts index 301570fa5..dc02a69e9 100644 --- a/src/renderer/api/modules/editorApi.test.ts +++ b/src/renderer/api/modules/editorApi.test.ts @@ -29,6 +29,7 @@ const canonicalDocument = (): EditorDocumentV1 => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index cd6fbc12f..89464618b 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -7,6 +7,7 @@ import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import Dropdown from '@components/main/common/Dropdown'; import ReloadButton from '@components/main/common/ReloadButton'; @@ -949,6 +950,7 @@ const Settings = ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }; assertCanonicalEditorDocument(candidate, 'keys_reset_all response'); diff --git a/src/renderer/editor/runtime/deleteFrozenSelection.ts b/src/renderer/editor/runtime/deleteFrozenSelection.ts index 07a4394c8..6a23d1f3d 100644 --- a/src/renderer/editor/runtime/deleteFrozenSelection.ts +++ b/src/renderer/editor/runtime/deleteFrozenSelection.ts @@ -2,6 +2,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -29,7 +30,7 @@ import { normalizeLayerGroupsForMode } from '@utils/layerGroupUtils'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import type { EditorOpV1 } from '@src/types/editor'; -type NativeType = 'key' | 'stat' | 'graph' | 'knob'; +type NativeType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; interface DeleteDocumentView { keys: Record; @@ -37,6 +38,7 @@ interface DeleteDocumentView { statPositions: Record>; graphPositions: Record>; knobPositions: Record>; + spritePositions: Record>; layerGroups: Record; } @@ -46,6 +48,7 @@ const storeView = (): DeleteDocumentView => ({ statPositions: useStatItemStore.getState().positions as never, graphPositions: useGraphItemStore.getState().positions as never, knobPositions: useKnobItemStore.getState().positions as never, + spritePositions: useSpriteStore.getState().positions as never, layerGroups: useLayerGroupStore.getState().layerGroups as never, }); @@ -98,6 +101,7 @@ export const deleteFrozenSelection = async ( stat: 'statPositions', graph: 'graphPositions', knob: 'knobPositions', + sprite: 'spritePositions', } as const; const removal = new Map>>(); let found = 0; @@ -133,6 +137,7 @@ export const deleteFrozenSelection = async ( statPositions: { ...document.statPositions }, graphPositions: { ...document.graphPositions }, knobPositions: { ...document.knobPositions }, + spritePositions: { ...document.spritePositions }, }; const affectedModes = new Set(); for (const [type, byMode] of removal) { @@ -143,7 +148,9 @@ export const deleteFrozenSelection = async ( ? 'statPositions' : type === 'graph' ? 'graphPositions' - : 'knobPositions'; + : type === 'knob' + ? 'knobPositions' + : 'spritePositions'; for (const [mode, indices] of byMode) { affectedModes.add(mode); next[field] = { @@ -191,6 +198,7 @@ export const deleteFrozenSelection = async ( statPositions: next.statPositions as never, graphPositions: next.graphPositions as never, knobPositions: next.knobPositions as never, + spritePositions: next.spritePositions as never, layerGroups: layerGroups as never, pluginElements: remainingPluginElements, }); @@ -198,6 +206,7 @@ export const deleteFrozenSelection = async ( next.statPositions = normalized.statPositions as never; next.graphPositions = normalized.graphPositions as never; next.knobPositions = normalized.knobPositions as never; + next.spritePositions = normalized.spritePositions as never; if (normalized.groupsChanged) { layerGroups = normalized.layerGroups as never; groupsChanged = true; @@ -226,6 +235,7 @@ export const deleteFrozenSelection = async ( 'statPositions', 'graphPositions', 'knobPositions', + 'spritePositions', 'layerGroups', ], mutate: () => { @@ -246,6 +256,9 @@ export const deleteFrozenSelection = async ( useKnobItemStore .getState() .setPositions(applied.next.knobPositions as never); + useSpriteStore + .getState() + .setPositions(applied.next.spritePositions as never); if (applied.groupsChanged) { useLayerGroupStore .getState() diff --git a/src/renderer/editor/runtime/editGestureController.ts b/src/renderer/editor/runtime/editGestureController.ts index ba98ea2c7..de958440a 100644 --- a/src/renderer/editor/runtime/editGestureController.ts +++ b/src/renderer/editor/runtime/editGestureController.ts @@ -8,6 +8,7 @@ import { previewApi } from '@api/modules/previewApi'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { PREVIEW_SCHEMA_VERSION, type PreviewDomain } from '@src/types/preview'; @@ -68,7 +69,9 @@ const authorityRecordFor = (domain: PreviewDomain): PositionsRecordLike => ? useStatItemStore.getState().positions : domain === 'graphPosition' ? useGraphItemStore.getState().positions - : useKnobItemStore.getState().positions) as PositionsRecordLike; + : domain === 'knobPosition' + ? useKnobItemStore.getState().positions + : useSpriteStore.getState().positions) as PositionsRecordLike; const currentIndexForId = ( domain: PreviewDomain, @@ -330,6 +333,7 @@ export const editGestureController = { statPosition: 'stat', graphPosition: 'graph', knobPosition: 'knob', + spritePosition: 'sprite', } as const; const propertyIntents: PropertyIntents = new Map( [...intents].map(([domain, resolved]) => [ diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index f2cc50e20..2ea63f1d2 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -76,6 +76,7 @@ const makeDocument = (key = 'A'): CanonicalEditorDocumentV1 => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); @@ -343,6 +344,7 @@ const applyOpsForTest = ( statPositions: next.statPositions, graphPositions: next.graphPositions, knobPositions: next.knobPositions, + spritePositions: next.spritePositions, layerGroups: next.layerGroups, pluginElements: [], }); diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 7d013ba89..1a619dbab 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -260,6 +260,7 @@ const SEMANTIC_POSITION_FIELDS: Record = { stat: 'statPositions', graph: 'graphPositions', knob: 'knobPositions', + sprite: 'spritePositions', }; const fieldsForSemanticOp = (op: EditorOpV1): EditorField[] => { @@ -483,6 +484,7 @@ const applySemanticOps = ( statPositions: next.statPositions, graphPositions: next.graphPositions, knobPositions: next.knobPositions, + spritePositions: next.spritePositions, layerGroups: next.layerGroups, pluginElements: currentPluginGroupMembers(), }); @@ -490,6 +492,7 @@ const applySemanticOps = ( next.statPositions = normalized.statPositions; next.graphPositions = normalized.graphPositions; next.knobPositions = normalized.knobPositions; + next.spritePositions = normalized.spritePositions; next.layerGroups = normalized.layerGroups; } return; @@ -504,6 +507,7 @@ const applySemanticOps = ( statPositions: next.statPositions, graphPositions: next.graphPositions, knobPositions: next.knobPositions, + spritePositions: next.spritePositions, layerGroups: next.layerGroups, pluginElements: currentPluginGroupMembers(), }); @@ -512,6 +516,7 @@ const applySemanticOps = ( next.statPositions = projected.statPositions; next.graphPositions = projected.graphPositions; next.knobPositions = projected.knobPositions; + next.spritePositions = projected.spritePositions; next.layerGroups = projected.layerGroups; return; } @@ -1058,6 +1063,7 @@ const applySemanticOps = ( statPositions: next.statPositions, graphPositions: next.graphPositions, knobPositions: next.knobPositions, + spritePositions: next.spritePositions, layerGroups: next.layerGroups, pluginElements: currentPluginGroupMembers(), }); @@ -1065,6 +1071,7 @@ const applySemanticOps = ( next.statPositions = normalized.statPositions; next.graphPositions = normalized.graphPositions; next.knobPositions = normalized.knobPositions; + next.spritePositions = normalized.spritePositions; next.layerGroups = normalized.layerGroups; break; } diff --git a/src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts b/src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts index 3e0e837b2..851c95273 100644 --- a/src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts +++ b/src/renderer/editor/runtime/editorGestureDiscardIntegration.test.ts @@ -57,6 +57,7 @@ const makeDocument = (): EditorDocumentV1 => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); diff --git a/src/renderer/editor/runtime/editorStateCoordinator.test.ts b/src/renderer/editor/runtime/editorStateCoordinator.test.ts index 430d1b116..64f94baff 100644 --- a/src/renderer/editor/runtime/editorStateCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorStateCoordinator.test.ts @@ -10,6 +10,7 @@ const runtime = vi.hoisted(() => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; let listener: ((event: EditorCommittedV1) => void) | null = null; diff --git a/src/renderer/editor/runtime/editorStateCoordinator.ts b/src/renderer/editor/runtime/editorStateCoordinator.ts index 26f525852..842e6bec7 100644 --- a/src/renderer/editor/runtime/editorStateCoordinator.ts +++ b/src/renderer/editor/runtime/editorStateCoordinator.ts @@ -8,6 +8,7 @@ import { useKeyStore, } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { invalidateSelectionForChangedIndexedElementArrays } from '@stores/grid/useGridSelectionStore'; @@ -58,6 +59,7 @@ export const captureEditorDocument = (): CanonicalEditorDocumentV1 => { statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }; }; @@ -75,6 +77,7 @@ export const applyEditorDocument = ( stat: useStatItemStore.getState().positions[mode] ?? [], graph: useGraphItemStore.getState().positions[mode] ?? [], knob: useKnobItemStore.getState().positions[mode] ?? [], + sprite: useSpriteStore.getState().positions[mode] ?? [], }, { keyMappings: document.keys[mode] ?? [], @@ -82,6 +85,7 @@ export const applyEditorDocument = ( stat: document.statPositions[mode] ?? [], graph: document.graphPositions[mode] ?? [], knob: document.knobPositions[mode] ?? [], + sprite: document.spritePositions[mode] ?? [], }, ); const keyChanges: Partial< @@ -117,6 +121,11 @@ export const applyEditorDocument = ( ) { useKnobItemStore.setState({ positions: document.knobPositions }); } + if ( + hasChanged(useSpriteStore.getState().positions, document.spritePositions) + ) { + useSpriteStore.setState({ positions: document.spritePositions }); + } if ( hasChanged( useLayerGroupStore.getState().layerGroups, diff --git a/src/renderer/editor/runtime/elementIntent.ts b/src/renderer/editor/runtime/elementIntent.ts index 4bfe7eb67..7542aa6bc 100644 --- a/src/renderer/editor/runtime/elementIntent.ts +++ b/src/renderer/editor/runtime/elementIntent.ts @@ -2,6 +2,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { stableStringify } from '@utils/core/stableStringify'; @@ -135,7 +136,9 @@ const readRecord = (type: NativeElementType): LooseRecord => ? useStatItemStore.getState().positions : type === 'graph' ? useGraphItemStore.getState().positions - : useKnobItemStore.getState().positions) as LooseRecord; + : type === 'knob' + ? useKnobItemStore.getState().positions + : useSpriteStore.getState().positions) as LooseRecord; const writeRecord = (type: NativeElementType, next: LooseRecord): void => { if (type === 'key') { @@ -144,8 +147,10 @@ const writeRecord = (type: NativeElementType, next: LooseRecord): void => { useStatItemStore.getState().setPositions(next as never); } else if (type === 'graph') { useGraphItemStore.getState().setPositions(next as never); - } else { + } else if (type === 'knob') { useKnobItemStore.getState().setPositions(next as never); + } else { + useSpriteStore.getState().setPositions(next as never); } }; @@ -259,12 +264,17 @@ export const applyPropertyIntentsEagerly = ( const GEOMETRY_FIELD_BY_TYPE: Record< NativeElementType, - 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'spritePositions' > = { key: 'keyPositions', stat: 'statPositions', graph: 'graphPositions', knob: 'knobPositions', + sprite: 'spritePositions', }; // 최신 base에서 기하 의도를 setBounds op으로 재생성한다. 크기는 base 값을 @@ -349,6 +359,7 @@ type SealedSliceField = | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'spritePositions' | 'layerGroups'; // --------------------------------------------------------------------------- @@ -369,6 +380,8 @@ const readFieldRecord = (field: SealedSliceField): Record => ? useGraphItemStore.getState().positions : field === 'knobPositions' ? useKnobItemStore.getState().positions + : field === 'spritePositions' + ? useSpriteStore.getState().positions : useLayerGroupStore.getState().layerGroups) as Record; const writeFieldModeSlices = ( @@ -393,6 +406,8 @@ const writeFieldModeSlices = ( useGraphItemStore.getState().setPositions(merged as never); } else if (field === 'knobPositions') { useKnobItemStore.getState().setPositions(merged as never); + } else if (field === 'spritePositions') { + useSpriteStore.getState().setPositions(merged as never); } else { useLayerGroupStore.getState().setLayerGroups(merged as never); } diff --git a/src/renderer/editor/runtime/elementOps.test.ts b/src/renderer/editor/runtime/elementOps.test.ts index cb6ab6f03..ef13b5e87 100644 --- a/src/renderer/editor/runtime/elementOps.test.ts +++ b/src/renderer/editor/runtime/elementOps.test.ts @@ -29,6 +29,7 @@ vi.mock('./editorSemanticOps', () => ({ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { registerLoadedPluginIdsProvider, @@ -40,6 +41,7 @@ import { addGraphAt, addKeyAt, addKnobAt, + addSpriteAt, addStatAt, commitBatchGeometryByIds, commitElementGeometryById, @@ -49,6 +51,7 @@ import { placeDuplicatedKey, placeDuplicatedGraph, placeDuplicatedKnob, + placeDuplicatedSprite, placeDuplicatedStat, patchElementHiddenById, setLayerGroupHidden, @@ -110,6 +113,7 @@ import type { EditorPatchV1, } from '@src/types/editor'; import type { GraphItemPosition } from '@src/types/key/graphItems'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; const ID_A = '11111111-1111-4111-8111-111111111111'; const ID_B = '22222222-2222-4222-8222-222222222222'; @@ -133,6 +137,29 @@ const graphAt = ( ...patch, }); +const spriteAt = (id: string): ReactiveSpritePosition & { id: string } => ({ + id, + dx: 0, + dy: 0, + width: 200, + height: 120, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'linear', +}); + // 슬롯 시점 base. 기본은 호출 시점 스토어 - 대기 중 재정렬·삭제는 테스트가 // slotBase로 재현한다 let slotBase: (() => CanonicalEditorDocumentV1) | null = null; @@ -145,6 +172,7 @@ const documentFromStores = (): CanonicalEditorDocumentV1 => statPositions: structuredClone(useStatItemStore.getState().positions), graphPositions: structuredClone(useGraphItemStore.getState().positions), knobPositions: structuredClone(useKnobItemStore.getState().positions), + spritePositions: structuredClone(useSpriteStore.getState().positions), layerGroups: structuredClone(useLayerGroupStore.getState().layerGroups), } as CanonicalEditorDocumentV1); @@ -202,6 +230,7 @@ describe('elementOps', () => { useStatItemStore.setState({ positions: {} }); useGraphItemStore.setState({ positions: {} }); useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); useLayerGroupStore.setState({ layerGroups: {} }); useGridSelectionStore.setState({ selectedElements: [] }); registerLoadedPluginIdsProvider(() => new Set()); @@ -377,6 +406,83 @@ describe('elementOps', () => { expect(useGridSelectionStore.getState().selectedElements).toEqual([]); }); + it('sprite 삽입·삭제·편입 전 실패 복원은 다른 컬렉션과 같은 계약을 따른다', async () => { + const spriteId = crypto.randomUUID(); + const sprite = spriteAt(spriteId); + + // 삽입: exact frozen op와 eager append + await expect(addSpriteAt('4key', sprite)).resolves.toBe(true); + const insertOp = api.commitSemanticOps.mock.calls[0][0][0]; + expect(insertOp).toMatchObject({ + kind: 'insertFrozenElements', + mode: '4key', + groups: [], + zUpdates: [], + elements: [{ elementType: 'sprite', position: { id: spriteId } }], + }); + expect( + useSpriteStore.getState().positions['4key'].map((p) => p.id), + ).toEqual([spriteId]); + + // 삭제의 편입 전 실패는 eager 제거를 복원한다 + api.commitSemanticOps.mockRejectedValueOnce(new Error('start failed')); + await expect(deleteElementById('sprite', spriteId)).rejects.toThrow( + 'start failed', + ); + expect( + useSpriteStore.getState().positions['4key'].map((p) => p.id), + ).toEqual([spriteId]); + + // 정상 삭제: deleteElement op와 스토어 제거 + await expect(deleteElementById('sprite', spriteId)).resolves.toBe(true); + expect(api.commitSemanticOps).toHaveBeenLastCalledWith( + [{ kind: 'deleteElement', elementType: 'sprite', id: spriteId }], + expect.objectContaining({ onEnrolled: expect.any(Function) }), + ); + expect(useSpriteStore.getState().positions['4key']).toEqual([]); + }); + + it('sprite 복제 사본은 poseId를 재발급하고 트리거·변환·이미지를 유지한다', async () => { + const source: ReactiveSpritePosition & { id: string } = { + ...spriteAt(crypto.randomUUID()), + poses: [ + { + poseId: 'pose-src-1', + triggers: [ID_A], + matchMode: 'exact', + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: 'sprites/override.png', + }, + { + poseId: 'pose-src-2', + triggers: [ID_A, ID_B], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }; + + await expect( + placeDuplicatedSprite('4key', source, 30, 40, 7), + ).resolves.toBe(true); + + const element = api.commitSemanticOps.mock.calls[0][0][0].elements[0]; + expect(element.elementType).toBe('sprite'); + expect(element.position).toMatchObject({ dx: 30, dy: 40, zIndex: 7 }); + // 사본 poseId는 재발급 - 원본과 공유하면 백엔드가 중복으로 거부. + // 트리거·변환·이미지는 원본과 동일 + const poses = element.position.poses as typeof source.poses; + expect(poses).toHaveLength(2); + poses.forEach((pose, index) => { + expect(pose.poseId).not.toBe(source.poses[index].poseId); + expect(pose).toEqual({ ...source.poses[index], poseId: pose.poseId }); + }); + expect(new Set(poses.map((pose) => pose.poseId)).size).toBe(2); + // eager 스토어 사본도 wire와 같은 재발급 poses를 갖는다 + expect(useSpriteStore.getState().positions['4key'][0].poses).toEqual(poses); + }); + it('신규 UUID가 기존 native ID와 충돌하면 eager와 wire를 모두 생략한다', async () => { const randomId = vi .spyOn(globalThis.crypto, 'randomUUID') @@ -621,6 +727,48 @@ describe('elementOps', () => { ]); }); + it('배치 정렬은 key+sprite 혼합 대상 모두 setBounds로 움직인다', async () => { + const sprite = { ...spriteAt(ID_B), dx: 50, dy: 10, width: 20, height: 30 }; + useKeyStore.setState({ + canonicalPositions: { + '4key': [{ ...keyAt(ID_A), dx: 10, dy: 0, width: 10, height: 20 }], + }, + positions: { + '4key': [{ ...keyAt(ID_A), dx: 10, dy: 0, width: 10, height: 20 }], + }, + }); + useSpriteStore.setState({ positions: { '4key': [sprite] } }); + api.lastAck = documentFromStores(); + + await commitBatchGeometryByIds({ + mode: '4key', + targets: [ + { type: 'key', id: ID_A }, + { type: 'sprite', id: ID_B }, + ], + operation: { kind: 'align', direction: 'right' }, + }); + + expect(api.commitGeneratedSemanticOps).toHaveBeenCalledOnce(); + const generate = api.commitGeneratedSemanticOps.mock.calls[0][0]; + expect(generate(documentFromStores())).toEqual([ + { + kind: 'setBounds', + elementType: 'key', + id: ID_A, + bounds: { dx: 60, dy: 0, width: 10, height: 20 }, + }, + { + kind: 'setBounds', + elementType: 'sprite', + id: ID_B, + bounds: { dx: 50, dy: 10, width: 20, height: 30 }, + }, + ]); + // eager: key가 sprite 오른끝에 즉시 정렬 + expect(useKeyStore.getState().canonicalPositions['4key'][0].dx).toBe(60); + }); + it('배치 geometry는 slot 최신 base에서 전체 계획을 다시 계산한다', async () => { useKeyStore.setState({ canonicalPositions: { diff --git a/src/renderer/editor/runtime/elementOps.ts b/src/renderer/editor/runtime/elementOps.ts index f396897d5..39700079a 100644 --- a/src/renderer/editor/runtime/elementOps.ts +++ b/src/renderer/editor/runtime/elementOps.ts @@ -1,6 +1,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; @@ -14,6 +15,7 @@ import { } from '../model/keys'; import { newElementId } from '../model/elementId'; import { cloneSlot } from '@utils/keySlot'; +import { reissueSpritePoseIds } from '@utils/sprite/poseIdentity'; import { stableStringify } from '@utils/core/stableStringify'; import { normalizeLayerGroupsForMode, @@ -72,6 +74,7 @@ import type { KeyPosition, KeySlot } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; import { paintPropertyFields, projectPaintDescriptor } from '@src/types/color'; import { projectElementShadowPatch } from '@src/types/key/shadows'; import { @@ -169,7 +172,9 @@ export const deleteElementById = ( ? useStatItemStore.getState() : type === 'graph' ? useGraphItemStore.getState() - : useKnobItemStore.getState(); + : type === 'knob' + ? useKnobItemStore.getState() + : useSpriteStore.getState(); removedPosition = (state.positions as unknown as LooseRecord)[ locator.mode ]?.[locator.index]; @@ -194,6 +199,7 @@ export const deleteElementById = ( statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, pluginElements: currentPluginGroupMembers(), }); @@ -205,6 +211,7 @@ export const deleteElementById = ( useStatItemStore.getState().setPositions(normalized.statPositions); useGraphItemStore.getState().setPositions(normalized.graphPositions); useKnobItemStore.getState().setPositions(normalized.knobPositions); + useSpriteStore.getState().setPositions(normalized.spritePositions); } if (normalized.groupsChanged) { const remaining = new Set( @@ -226,7 +233,9 @@ export const deleteElementById = ( ? 'statPositions' : type === 'graph' ? 'graphPositions' - : 'knobPositions' + : type === 'knob' + ? 'knobPositions' + : 'spritePositions' ] as LooseRecord | undefined; if (canonicalRecord && !findInRecord(canonicalRecord, id)) return; const currentRecord = @@ -237,7 +246,9 @@ export const deleteElementById = ( ? (useStatItemStore.getState().positions as unknown as LooseRecord) : type === 'graph' ? (useGraphItemStore.getState().positions as unknown as LooseRecord) - : (useKnobItemStore.getState().positions as unknown as LooseRecord); + : type === 'knob' + ? (useKnobItemStore.getState().positions as unknown as LooseRecord) + : (useSpriteStore.getState().positions as unknown as LooseRecord); // 다른 경로가 같은 ID를 이미 복원·갱신했으면 그룹까지 그 경로 소유 if (findInRecord(currentRecord, id)) return; if (removedGroups.length > 0) { @@ -296,7 +307,9 @@ export const deleteElementById = ( ? useStatItemStore.getState() : type === 'graph' ? useGraphItemStore.getState() - : useKnobItemStore.getState(); + : type === 'knob' + ? useKnobItemStore.getState() + : useSpriteStore.getState(); const record = state.positions as unknown as LooseRecord; if (findInRecord(record, id)) return; const list = record[locator.mode] ?? []; @@ -344,6 +357,7 @@ const documentHasElementId = ( document.statPositions, document.graphPositions, document.knobPositions, + document.spritePositions, ].some((record) => findInRecord(record as unknown as LooseRecord, id)); const documentHasExactFrozenElement = ( @@ -358,7 +372,9 @@ const documentHasExactFrozenElement = ( ? 'statPositions' : element.elementType === 'graph' ? 'graphPositions' - : 'knobPositions'; + : element.elementType === 'knob' + ? 'knobPositions' + : 'spritePositions'; const index = document[field][mode]?.findIndex( (position) => position.id === element.position.id, ); @@ -411,7 +427,9 @@ const insertFrozenElement = ( ? useStatItemStore.getState() : element.elementType === 'graph' ? useGraphItemStore.getState() - : useKnobItemStore.getState(); + : element.elementType === 'knob' + ? useKnobItemStore.getState() + : useSpriteStore.getState(); const positions = state.positions as unknown as LooseRecord; state.setPositions({ ...positions, @@ -451,7 +469,9 @@ const insertFrozenElement = ( ? useStatItemStore.getState() : element.elementType === 'graph' ? useGraphItemStore.getState() - : useKnobItemStore.getState(); + : element.elementType === 'knob' + ? useKnobItemStore.getState() + : useSpriteStore.getState(); const positions = state.positions as unknown as LooseRecord; const located = findInRecord(positions, id); if (!located) return; @@ -509,6 +529,11 @@ export const addKnobAt = ( position: KnobItemPosition & { id: string }, ) => insertFrozenElement(mode, { elementType: 'knob', position }); +export const addSpriteAt = ( + mode: string, + position: ReactiveSpritePosition & { id: string }, +) => insertFrozenElement(mode, { elementType: 'sprite', position }); + const groupForMode = (mode: string, groupId: string | undefined) => groupId && (useLayerGroupStore.getState().layerGroups[mode] ?? []).some( @@ -591,14 +616,42 @@ export const placeDuplicatedKnob = ( }, }); +export const placeDuplicatedSprite = ( + mode: string, + source: ReactiveSpritePosition, + dx: number, + dy: number, + zIndex: number, +) => { + const cloned = structuredClone(source); + return insertFrozenElement(mode, { + elementType: 'sprite', + position: { + ...cloned, + id: newElementId(), + // 사본 poseId 재발급 - 원본과 공유하면 백엔드가 중복으로 거부 + poses: reissueSpritePoseIds(cloned.poses), + groupId: groupForMode(mode, source.groupId ?? undefined) ?? null, + dx, + dy, + zIndex, + }, + }); +}; + const FIELD_BY_TYPE: Record< NativeElementType, - 'keyPositions' | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'keyPositions' + | 'statPositions' + | 'graphPositions' + | 'knobPositions' + | 'spritePositions' > = { key: 'keyPositions', stat: 'statPositions', graph: 'graphPositions', knob: 'knobPositions', + sprite: 'spritePositions', }; // 키 슬롯 재바인딩: keys만 바꾸되 대상은 paired 위치의 안정 id로 재결합한다. @@ -665,6 +718,7 @@ const POSITION_FIELD_BY_TYPE: Record = { stat: 'statPositions', graph: 'graphPositions', knob: 'knobPositions', + sprite: 'spritePositions', }; // 현재 문서의 요소 레코드 (없으면 null) @@ -972,7 +1026,7 @@ const validElementGroupTargets = ( targets.length <= 4096 && targets.every( ({ elementType, id }) => - ['key', 'stat', 'graph', 'knob'].includes(elementType) && + ['key', 'stat', 'graph', 'knob', 'sprite'].includes(elementType) && isNativeElementId(id), ) && new Set(targets.map(({ id }) => id)).size === targets.length; @@ -1004,6 +1058,7 @@ export const setElementGroupsByTargets = ( statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }; const projected = projectStableElementGroups({ @@ -1019,6 +1074,7 @@ export const setElementGroupsByTargets = ( useStatItemStore.getState().setPositions(projected.statPositions); useGraphItemStore.getState().setPositions(projected.graphPositions); useKnobItemStore.getState().setPositions(projected.knobPositions); + useSpriteStore.getState().setPositions(projected.spritePositions); useLayerGroupStore.getState().setLayerGroups(projected.layerGroups); } let enrolled = false; @@ -1057,6 +1113,9 @@ export const setElementGroupsByTargets = ( if (useKnobItemStore.getState().positions === projected.knobPositions) { useKnobItemStore.getState().setPositions(before.knobPositions); } + if (useSpriteStore.getState().positions === projected.spritePositions) { + useSpriteStore.getState().setPositions(before.spritePositions); + } if ( useLayerGroupStore.getState().layerGroups === projected.layerGroups ) { @@ -1899,6 +1958,8 @@ const paintPropertyIntents = ( Map> >(); for (const { elementType, id } of targets) { + // 스프라이트는 페인트 속성이 없다 + if (elementType === 'sprite') continue; const collection = elementType === 'key' ? document.keyPositions @@ -2082,7 +2143,7 @@ const shadowPropertyIntents = ( Map> >(); for (const { elementType, id } of targets) { - if (elementType === 'graph') continue; + if (elementType === 'graph' || elementType === 'sprite') continue; const collection = elementType === 'key' ? document.keyPositions diff --git a/src/renderer/editor/runtime/layerZOrderIntent.test.ts b/src/renderer/editor/runtime/layerZOrderIntent.test.ts index fc939e479..3b180434d 100644 --- a/src/renderer/editor/runtime/layerZOrderIntent.test.ts +++ b/src/renderer/editor/runtime/layerZOrderIntent.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -64,6 +65,7 @@ const documentWith = (options?: { }, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, } as never); @@ -93,6 +95,7 @@ const install = (document: CanonicalEditorDocumentV1) => { useStatItemStore.setState({ positions: document.statPositions }); useGraphItemStore.setState({ positions: document.graphPositions }); useKnobItemStore.setState({ positions: document.knobPositions }); + useSpriteStore.setState({ positions: document.spritePositions }); useLayerGroupStore.setState({ layerGroups: {}, collapsedGroups: new Set() }); }; diff --git a/src/renderer/editor/runtime/layerZOrderIntent.ts b/src/renderer/editor/runtime/layerZOrderIntent.ts index 47b59f69d..afb23daa6 100644 --- a/src/renderer/editor/runtime/layerZOrderIntent.ts +++ b/src/renderer/editor/runtime/layerZOrderIntent.ts @@ -1,6 +1,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -66,6 +67,7 @@ const localDocument = (): CanonicalEditorDocumentV1 => ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }); @@ -78,6 +80,7 @@ const nativeEntries = ( ['stat', document.statPositions], ['graph', document.graphPositions], ['knob', document.knobPositions], + ['sprite', document.spritePositions], ] as const; return records.flatMap(([type, record]) => (record[mode] ?? []).map((position, index) => ({ diff --git a/src/renderer/editor/runtime/mixedBatchGeometry.ts b/src/renderer/editor/runtime/mixedBatchGeometry.ts index fffccf517..9a7bc407f 100644 --- a/src/renderer/editor/runtime/mixedBatchGeometry.ts +++ b/src/renderer/editor/runtime/mixedBatchGeometry.ts @@ -5,6 +5,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { @@ -55,7 +56,8 @@ const validNativeGeometryTargets = ( descriptor.targets.length <= MAX_BATCH_GEOMETRY_TARGETS && descriptor.targets.every( ({ type, id }) => - ['key', 'stat', 'graph', 'knob'].includes(type) && isNativeElementId(id), + ['key', 'stat', 'graph', 'knob', 'sprite'].includes(type) && + isNativeElementId(id), ) && new Set(descriptor.targets.map(({ id }) => id)).size === descriptor.targets.length; @@ -72,7 +74,9 @@ const readStorePositions = (type: NativeElementType): PositionSlice => ? useStatItemStore.getState().positions : type === 'graph' ? useGraphItemStore.getState().positions - : useKnobItemStore.getState().positions) as unknown as PositionSlice; + : type === 'knob' + ? useKnobItemStore.getState().positions + : useSpriteStore.getState().positions) as unknown as PositionSlice; const readDocumentPositions = (base: CanonicalEditorDocumentV1) => @@ -83,7 +87,9 @@ const readDocumentPositions = ? base.statPositions : type === 'graph' ? base.graphPositions - : base.knobPositions) as unknown as PositionSlice; + : type === 'knob' + ? base.knobPositions + : base.spritePositions) as unknown as PositionSlice; // native 대상 bounds 수집 - 지정 모드에서의 소실·비유한 값은 전체 거절 const readNativeLayoutElements = ( diff --git a/src/renderer/editor/runtime/mixedCommitRegeneration.test.ts b/src/renderer/editor/runtime/mixedCommitRegeneration.test.ts index eddf81d0d..509fde374 100644 --- a/src/renderer/editor/runtime/mixedCommitRegeneration.test.ts +++ b/src/renderer/editor/runtime/mixedCommitRegeneration.test.ts @@ -28,6 +28,7 @@ const makeDocument = (): CanonicalEditorDocumentV1 => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); diff --git a/src/renderer/editor/runtime/mixedElementGroups.test.ts b/src/renderer/editor/runtime/mixedElementGroups.test.ts index def6bf426..5de970a2b 100644 --- a/src/renderer/editor/runtime/mixedElementGroups.test.ts +++ b/src/renderer/editor/runtime/mixedElementGroups.test.ts @@ -5,6 +5,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; @@ -69,14 +70,21 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ await meta?.prepare?.(); // 그룹 멤버를 base에서 재파생하는 generator를 위해 현재 스토어로 // canonical base를 구성한다 (기존 setElementGroups 경로는 base 미사용) - const [keyStore, statStore, graphStore, knobStore, groupStore] = - await Promise.all([ - import('@stores/data/useKeyStore'), - import('@stores/data/useStatItemStore'), - import('@stores/data/useGraphItemStore'), - import('@stores/data/useKnobItemStore'), - import('@stores/data/useLayerGroupStore'), - ]); + const [ + keyStore, + statStore, + graphStore, + knobStore, + spriteStore, + groupStore, + ] = await Promise.all([ + import('@stores/data/useKeyStore'), + import('@stores/data/useStatItemStore'), + import('@stores/data/useGraphItemStore'), + import('@stores/data/useKnobItemStore'), + import('@stores/data/useSpriteStore'), + import('@stores/data/useLayerGroupStore'), + ]); const base = { schemaVersion: 1, keys: keyStore.useKeyStore.getState().keyMappings, @@ -84,6 +92,7 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ statPositions: statStore.useStatItemStore.getState().positions, graphPositions: graphStore.useGraphItemStore.getState().positions, knobPositions: knobStore.useKnobItemStore.getState().positions, + spritePositions: spriteStore.useSpriteStore.getState().positions, layerGroups: groupStore.useLayerGroupStore.getState().layerGroups, }; const mutation = @@ -119,6 +128,7 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }), })); @@ -222,6 +232,7 @@ const seedStores = () => { useStatItemStore.setState({ positions: {} }); useGraphItemStore.setState({ positions: {} }); useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); useLayerGroupStore.setState({ layerGroups: {} }); usePluginDisplayElementStore.setState({ elements: [pluginElement()] }); }; @@ -433,6 +444,7 @@ describe('setMixedLayerGroupHidden', () => { useStatItemStore.setState({ positions: {} }); useGraphItemStore.setState({ positions: {} }); useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); useLayerGroupStore.setState({ layerGroups: { '4key': [{ id: GROUP_ID, name: 'Group 1' }] }, }); @@ -495,6 +507,79 @@ describe('setMixedLayerGroupHidden', () => { expect(mocks.setLayerGroupHidden).not.toHaveBeenCalled(); }); + it('sprite+plugin 그룹 토글은 sprite를 patchElement 목록에 싣고 스토어에도 숨긴다', async () => { + const SPRITE_ID = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + useSpriteStore.setState({ + positions: { + '4key': [ + { + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 120, + hidden: false, + zIndex: null, + layerName: null, + groupId: GROUP_ID, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'linear', + }, + ], + }, + }); + useLayerGroupStore.setState({ + layerGroups: { '4key': [{ id: GROUP_ID, name: 'Group 1' }] }, + }); + usePluginDisplayElementStore.setState({ + elements: [pluginElement({ groupId: GROUP_ID })], + }); + + await expect( + setMixedLayerGroupHidden('4key', GROUP_ID, true), + ).resolves.toBe(true); + + expect(mocks.gestureCommit).toHaveBeenCalledTimes(1); + const request = mocks.gestureCommit.mock.calls[0]?.[0] as unknown as { + editorOps: unknown[]; + pluginChanges: Array<{ + pluginId: string; + instances: Array<{ instanceId?: string; hidden?: boolean }>; + }>; + }; + expect(request.editorOps).toEqual([ + { + kind: 'patchElement', + elementType: 'sprite', + id: SPRITE_ID, + patch: { property: 'hidden', value: true }, + }, + ]); + expect(request.pluginChanges).toEqual([ + { + pluginId: 'plugin-a', + instances: [ + expect.objectContaining({ instanceId: INSTANCE_ID, hidden: true }), + ], + }, + ]); + // eager: sprite 스토어에도 즉시 숨김 반영 + expect(useSpriteStore.getState().positions['4key'][0].hidden).toBe(true); + expect(usePluginDisplayElementStore.getState().elements[0].hidden).toBe( + true, + ); + expect(mocks.setLayerGroupHidden).not.toHaveBeenCalled(); + }); + it('플러그인 멤버가 없으면 기존 native 전용 경로에 위임한다', async () => { // seedStores 기본: 플러그인 무소속, key도 무소속 await expect( diff --git a/src/renderer/editor/runtime/mixedElementGroups.ts b/src/renderer/editor/runtime/mixedElementGroups.ts index f43666907..d40e35588 100644 --- a/src/renderer/editor/runtime/mixedElementGroups.ts +++ b/src/renderer/editor/runtime/mixedElementGroups.ts @@ -6,6 +6,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { @@ -47,7 +48,7 @@ const validNativeGroupTargets = ( targets.length <= 4096 && targets.every( ({ elementType, id }) => - ['key', 'stat', 'graph', 'knob'].includes(elementType) && + ['key', 'stat', 'graph', 'knob', 'sprite'].includes(elementType) && isNativeElementId(id), ) && new Set(targets.map(({ id }) => id)).size === targets.length; @@ -185,6 +186,7 @@ export const setMixedElementGroups = ( statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }; const projected = projectStableElementGroups({ @@ -206,6 +208,7 @@ export const setMixedElementGroups = ( useStatItemStore.getState().setPositions(projected.statPositions); useGraphItemStore.getState().setPositions(projected.graphPositions); useKnobItemStore.getState().setPositions(projected.knobPositions); + useSpriteStore.getState().setPositions(projected.spritePositions); useLayerGroupStore.getState().setLayerGroups(projected.layerGroups); nativeReceipt = { rollback: () => { @@ -232,6 +235,11 @@ export const setMixedElementGroups = ( ) { useKnobItemStore.getState().setPositions(before.knobPositions); } + if ( + useSpriteStore.getState().positions === projected.spritePositions + ) { + useSpriteStore.getState().setPositions(before.spritePositions); + } if ( useLayerGroupStore.getState().layerGroups === projected.layerGroups @@ -313,6 +321,7 @@ const NATIVE_MEMBER_TYPES: readonly NativeElementType[] = [ 'stat', 'graph', 'knob', + 'sprite', ]; // 그룹의 native 멤버 수집 - canonical ID가 아닌 멤버가 섞이면 반쪽 토글 @@ -342,7 +351,9 @@ const readStorePositions = (type: NativeElementType): PositionSlice => ? useStatItemStore.getState().positions : type === 'graph' ? useGraphItemStore.getState().positions - : useKnobItemStore.getState().positions) as unknown as PositionSlice; + : type === 'knob' + ? useKnobItemStore.getState().positions + : useSpriteStore.getState().positions) as unknown as PositionSlice; const readDocumentPositions = (base: CanonicalEditorDocumentV1) => @@ -353,7 +364,9 @@ const readDocumentPositions = ? base.statPositions : type === 'graph' ? base.graphPositions - : base.knobPositions) as unknown as PositionSlice; + : type === 'knob' + ? base.knobPositions + : base.spritePositions) as unknown as PositionSlice; // 플러그인 hidden eager 반영 - CAS receipt (groupId eager 패턴과 동일) const applyPluginHiddenEagerly = ( diff --git a/src/renderer/editor/runtime/mixedMoveSettlementIntegration.test.ts b/src/renderer/editor/runtime/mixedMoveSettlementIntegration.test.ts index 511af59ca..41e37e9c9 100644 --- a/src/renderer/editor/runtime/mixedMoveSettlementIntegration.test.ts +++ b/src/renderer/editor/runtime/mixedMoveSettlementIntegration.test.ts @@ -88,6 +88,7 @@ function makeDocument(): EditorDocumentV1 { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; } diff --git a/src/renderer/editor/runtime/paintGestureDiscardIntegration.test.ts b/src/renderer/editor/runtime/paintGestureDiscardIntegration.test.ts index 97f5d4cc4..c45e27fab 100644 --- a/src/renderer/editor/runtime/paintGestureDiscardIntegration.test.ts +++ b/src/renderer/editor/runtime/paintGestureDiscardIntegration.test.ts @@ -68,6 +68,7 @@ const makeDocument = (): EditorDocumentV1 => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); diff --git a/src/renderer/editor/runtime/pluginGroupMembers.test.ts b/src/renderer/editor/runtime/pluginGroupMembers.test.ts index 0153257ea..98581f714 100644 --- a/src/renderer/editor/runtime/pluginGroupMembers.test.ts +++ b/src/renderer/editor/runtime/pluginGroupMembers.test.ts @@ -14,6 +14,7 @@ const emptyPositions = { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, }; beforeEach(() => { diff --git a/src/renderer/hooks/Grid/mixedDragSettlement.e2e.test.tsx b/src/renderer/hooks/Grid/mixedDragSettlement.e2e.test.tsx index 9500a1a9d..385879234 100644 --- a/src/renderer/hooks/Grid/mixedDragSettlement.e2e.test.tsx +++ b/src/renderer/hooks/Grid/mixedDragSettlement.e2e.test.tsx @@ -83,6 +83,7 @@ function makeDocument(): EditorDocumentV1 { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; } diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index 2a66fd7e5..76c6d365f 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -7,9 +7,13 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; -import type { CanonicalKeyPosition } from '@src/types/editor'; +import type { + CanonicalKeyPosition, + CanonicalReactiveSpritePosition, +} from '@src/types/editor'; import type { PluginDefinitionInternal, PluginDisplayElementInternal, @@ -124,6 +128,37 @@ const STABLE_KEY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const STABLE_STAT_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const STABLE_GRAPH_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const STABLE_KNOB_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const STABLE_SPRITE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ + id, + dx: 15, + dy: 25, + width: 200, + height: 120, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + poseId: 'pose-1', + triggers: [STABLE_KEY_ID], + matchMode: 'exact', + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'linear', +}); const keyPosition = { id: STABLE_KEY_ID, dx: 10, @@ -200,6 +235,7 @@ describe('useGridSelection compound history gesture', () => { useStatItemStore.setState({ positions: {} }); useGraphItemStore.setState({ positions: {} }); useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); useLayerGroupStore.setState({ layerGroups: {} }); usePluginDisplayElementStore.setState({ elements: [pluginElement()], @@ -388,6 +424,81 @@ describe('useGridSelection compound history gesture', () => { ]); }); + it('sprite 복사-붙여넣기 왕복은 poseId만 재발급하고 나머지 로컬 데이터를 유지한다', async () => { + const original = spriteAt(STABLE_SPRITE_ID); + act(() => { + useSpriteStore.setState({ positions: { '4key': [original] } }); + useGridSelectionStore + .getState() + .setSelectedElements([ + { type: 'sprite', id: STABLE_SPRITE_ID, index: 0 }, + ]); + }); + + act(() => api.copySelectedElements()); + expect(useGridSelectionStore.getState().clipboard).toEqual([ + { type: 'sprite', position: original }, + ]); + + await act(async () => api.pasteElements()); + + // eager: 사본은 새 id로 추가되고 dx·dy만 PASTE_OFFSET만큼 어긋난다 + const sprites = useSpriteStore.getState().positions['4key']; + expect(sprites).toHaveLength(2); + const pasted = sprites.find( + (position) => position.id !== STABLE_SPRITE_ID, + )!; + expect(pasted.dx).toBe(original.dx + PASTE_OFFSET); + expect(pasted.dy).toBe(original.dy + PASTE_OFFSET); + expect(pasted.width).toBe(original.width); + expect(pasted.height).toBe(original.height); + expect(pasted.imageRect).toEqual(original.imageRect); + expect(pasted.pivot).toEqual(original.pivot); + // 사본 poseId는 재발급 - 원본과 공유하면 백엔드 커밋이 중복으로 거부. + // 트리거·변환·이미지는 원본과 동일 + expect(pasted.poses).toHaveLength(original.poses.length); + expect(pasted.poses[0].poseId).not.toBe(original.poses[0].poseId); + expect(pasted.poses[0]).toEqual({ + ...original.poses[0], + poseId: pasted.poses[0].poseId, + }); + + // wire: 동결 payload가 sprite full payload로 batch op에 실린다 + const options = ( + mocks.runMixedGestureIntent.mock.calls[0] as unknown[] + )[0] as { + generate: (context: { base: unknown; pluginProjection: unknown[] }) => { + ops?: Array<{ + elements: Array<{ + elementType: string; + position: Record; + }>; + }>; + }; + }; + const result = options.generate({ + base: { + schemaVersion: 1, + keys: { '4key': [] }, + keyPositions: { '4key': [] }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: { '4key': [] }, + layerGroups: {}, + }, + pluginProjection: [], + }); + const spriteElement = result.ops![0].elements.find( + (element) => element.elementType === 'sprite', + )!; + expect(spriteElement.position.id).toBe(pasted.id); + expect(spriteElement.position.dx).toBe(original.dx + PASTE_OFFSET); + expect(spriteElement.position.imageRect).toEqual(original.imageRect); + // wire payload도 eager 사본과 같은 재발급 poseId를 싣는다 + expect(spriteElement.position.poses).toEqual(pasted.poses); + }); + it('혼합 붙여넣기는 editor와 plugin에 같은 gestureId를 전달한다', async () => { act(() => { useGridSelectionStore.getState().setClipboard([ @@ -832,6 +943,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; const [existing, pasted] = usePluginDisplayElementStore.getState().elements; @@ -1095,6 +1207,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }); @@ -1412,6 +1525,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; const result = options.generate({ base: emptyBase, pluginProjection: [] }); @@ -1498,6 +1612,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: { '4key': [] }, graphPositions: { '4key': [] }, knobPositions: { '4key': [] }, + spritePositions: { '4key': [] }, layerGroups: {}, }, pluginProjection: [], @@ -1555,6 +1670,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; expect(() => @@ -1598,6 +1714,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }; const frozenGroup = groupedOptions.generate({ @@ -1683,6 +1800,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }, pluginProjection: [], @@ -1734,6 +1852,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }, pluginProjection: [], @@ -1790,6 +1909,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }, pluginProjection: [], @@ -1886,6 +2006,7 @@ describe('useGridSelection compound history gesture', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: { '4key': [{ id: 'g1', name: 'g1' }] }, }; const result = options.generate({ base, pluginProjection: [] }); diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index 423e9e69d..d558f989a 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -11,6 +11,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { @@ -21,6 +22,7 @@ import { import { PASTE_OFFSET } from './constants'; import type { KeyMappings, KeySlot } from '@src/types/key/keys'; import { cloneSlot } from '@utils/keySlot'; +import { reissueSpritePoseIds } from '@utils/sprite/poseIdentity'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { buildNextLayerGroupName, @@ -51,6 +53,7 @@ import type { CanonicalStatItemPosition, CanonicalGraphItemPosition, CanonicalKnobItemPosition, + CanonicalReactiveSpritePosition, EditorInsertFrozenElementsOpV1, } from '@src/types/editor'; import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; @@ -146,6 +149,7 @@ export function useGridSelection({ const currentStatPositions = useStatItemStore.getState().positions; const currentGraphPositions = useGraphItemStore.getState().positions; const currentKnobPositions = useKnobItemStore.getState().positions; + const currentSpritePositions = useSpriteStore.getState().positions; const frozen = frozenTargets ?? frozenGestureTargetsRef.current; frozenGestureTargetsRef.current = null; const currentSelection = @@ -176,7 +180,7 @@ export function useGridSelection({ element.type !== 'plugin', ) .map((element) => ({ - type: element.type as 'key' | 'stat' | 'graph' | 'knob', + type: element.type as 'key' | 'stat' | 'graph' | 'knob' | 'sprite', id: element.id, })); const allStableIds = @@ -198,7 +202,7 @@ export function useGridSelection({ // 이동 정산은 dx·dy만 동결 - width·height까지 실으면 병행 리사이즈를 // 되돌린다. wire는 슬롯 generator가 최신 base에 id 의도를 재적용 const geometryIntents: PropertyIntents = new Map( - (['key', 'stat', 'graph', 'knob'] as const).map((type) => [ + (['key', 'stat', 'graph', 'knob', 'sprite'] as const).map((type) => [ type, new Map( nativeTargets @@ -212,7 +216,9 @@ export function useGridSelection({ ? currentStatPositions : type === 'graph' ? currentGraphPositions - : currentKnobPositions; + : type === 'knob' + ? currentKnobPositions + : currentSpritePositions; const position = locator ? ( record as Record< @@ -245,7 +251,9 @@ export function useGridSelection({ ? 'statPositions' : type === 'graph' ? 'graphPositions' - : 'knobPositions'; + : type === 'knob' + ? 'knobPositions' + : 'spritePositions'; const collections = lastAck[field] as Record< string, Array<{ id: string } & Record> @@ -460,6 +468,32 @@ export function useGridSelection({ useKnobItemStore.getState().setPositions(newKnobPositions); } + // 스프라이트 요소 배치 업데이트 + const spriteUpdates = selectedElements.filter((el) => el.type === 'sprite'); + if (spriteUpdates.length > 0) { + const currentSpritePositions = useSpriteStore.getState().positions; + const newSpritePositions = { ...currentSpritePositions }; + const tabPositions = [...(newSpritePositions[selectedKeyType] || [])]; + + spriteUpdates.forEach((el) => { + const index = tabPositions.findIndex( + (position) => position.id === el.id, + ); + if (index < 0) return; + const currentPos = tabPositions[index]; + if (currentPos) { + tabPositions[index] = { + ...currentPos, + dx: currentPos.dx + deltaX, + dy: currentPos.dy + deltaY, + }; + } + }); + + newSpritePositions[selectedKeyType] = tabPositions; + useSpriteStore.getState().setPositions(newSpritePositions); + } + // 플러그인 요소 배치 업데이트 const pluginUpdates = selectedElements.filter((el) => el.type === 'plugin'); let stagedBeforeEagerWrite = false; @@ -541,6 +575,8 @@ export function useGridSelection({ useGraphItemStore.getState().positions[selectedKeyType] || []; const currentKnobPositions = useKnobItemStore.getState().positions[selectedKeyType] || []; + const currentSpritePositions = + useSpriteStore.getState().positions[selectedKeyType] || []; const currentPluginElements = usePluginDisplayElementStore.getState().elements; @@ -591,6 +627,16 @@ export function useGridSelection({ position: { ...position }, }); } + } else if (element.type === 'sprite') { + const position = currentSpritePositions.find( + (candidate) => candidate.id === element.id, + ); + if (position) { + clipboardItems.push({ + type: 'sprite', + position: { ...position }, + }); + } } else if (element.type === 'plugin') { const pluginElement = currentPluginElements.find( (el) => el.fullId === element.id, @@ -695,6 +741,7 @@ export function useGridSelection({ const statsToAdd: { position: CanonicalStatItemPosition }[] = []; const graphsToAdd: { position: CanonicalGraphItemPosition }[] = []; const knobsToAdd: { position: CanonicalKnobItemPosition }[] = []; + const spritesToAdd: { position: CanonicalReactiveSpritePosition }[] = []; const pluginPayloads: Omit[] = []; for (const item of currentClipboard) { if (item.type === 'key') { @@ -738,6 +785,18 @@ export function useGridSelection({ dy: (item.position.dy || 0) + PASTE_OFFSET, }, }); + } else if (item.type === 'sprite') { + spritesToAdd.push({ + position: { + ...item.position, + id: newElementId(), + // 사본 poseId 재발급 - 원본과 공유하면 백엔드가 중복으로 거부 + poses: reissueSpritePoseIds(item.position.poses), + groupId: remapGroupId(item.position.groupId ?? undefined) ?? null, + dx: (item.position.dx || 0) + PASTE_OFFSET, + dy: (item.position.dy || 0) + PASTE_OFFSET, + }, + }); } else if (item.type === 'plugin') { pluginPayloads.push({ ...item.element, @@ -816,6 +875,7 @@ export function useGridSelection({ statsToAdd.length > 0 || graphsToAdd.length > 0 || knobsToAdd.length > 0 || + spritesToAdd.length > 0 || clipboardGroups.length > 0; if (!hasEditorPaste && frozenPluginElements.length === 0) return; @@ -826,6 +886,7 @@ export function useGridSelection({ useStatItemStore.getState().positions, useGraphItemStore.getState().positions, useKnobItemStore.getState().positions, + useSpriteStore.getState().positions, usePluginDisplayElementStore.getState().elements, ); // 앵커 descriptor: 선택 요소와 선택 그룹 최상단 중 더 위를 동결하되, @@ -879,6 +940,7 @@ export function useGridSelection({ statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; layerGroups: Record>; } @@ -914,6 +976,7 @@ export function useGridSelection({ 'statPositions', 'graphPositions', 'knobPositions', + 'spritePositions', ] as const; for (const field of fields) { for (const [ownMode, list] of Object.entries(view[field])) { @@ -931,6 +994,7 @@ export function useGridSelection({ const nextStatPositions = { ...view.statPositions }; const nextGraphPositions = { ...view.graphPositions }; const nextKnobPositions = { ...view.knobPositions }; + const nextSpritePositions = { ...view.spritePositions }; const appendedNativeIds = new Set(); let realizedFrozenNativeParts = 0; @@ -1011,6 +1075,11 @@ export function useGridSelection({ knobsToAdd, 'knobPositions', ); + const spriteNext = appendSimple( + nextSpritePositions, + spritesToAdd, + 'spritePositions', + ); // 신규 그룹 append (id 기준 멱등) let layerGroups = view.layerGroups; @@ -1073,6 +1142,7 @@ export function useGridSelection({ statNext as never, graphNext as never, knobNext as never, + spriteNext as never, combinedProjection, ); const newIdSet = new Set([ @@ -1105,6 +1175,10 @@ export function useGridSelection({ item: entry.position.id, zIndex: entry.position.zIndex, })), + ...spritesToAdd.map((entry) => ({ + item: entry.position.id, + zIndex: entry.position.zIndex ?? undefined, + })), ...frozenPluginElements.map((element) => ({ item: element.fullId, zIndex: element.zIndex, @@ -1136,6 +1210,7 @@ export function useGridSelection({ statNext as never, graphNext as never, knobNext as never, + spriteNext as never, ); const zByFullId = new Map( zPatch.pluginUpdates.map((update) => [update.fullId, update.zIndex]), @@ -1172,6 +1247,7 @@ export function useGridSelection({ ...statsToAdd.map((entry) => entry.position.id), ...graphsToAdd.map((entry) => entry.position.id), ...knobsToAdd.map((entry) => entry.position.id), + ...spritesToAdd.map((entry) => entry.position.id), ]); const finalById = ( record: Record, @@ -1183,6 +1259,7 @@ export function useGridSelection({ const finalStats = finalById(plan.zPatch.statPositions); const finalGraphs = finalById(plan.zPatch.graphPositions); const finalKnobs = finalById(plan.zPatch.knobPositions); + const finalSprites = finalById(plan.zPatch.spritePositions); const elements: EditorInsertFrozenElementsOpV1['elements'] = [ ...keysToAdd.map((entry) => ({ elementType: 'key' as const, @@ -1201,10 +1278,14 @@ export function useGridSelection({ elementType: 'knob' as const, position: finalKnobs.get(entry.position.id) ?? entry.position, })), + ...spritesToAdd.map((entry) => ({ + elementType: 'sprite' as const, + position: finalSprites.get(entry.position.id) ?? entry.position, + })), ]; const zUpdates: EditorInsertFrozenElementsOpV1['zUpdates'] = []; const collectZUpdates = ( - elementType: 'key' | 'stat' | 'graph' | 'knob', + elementType: 'key' | 'stat' | 'graph' | 'knob' | 'sprite', current: Array<{ id: string }>, final: Map, ) => { @@ -1226,7 +1307,12 @@ export function useGridSelection({ view.graphPositions[mode] ?? [], finalGraphs, ) && - collectZUpdates('knob', view.knobPositions[mode] ?? [], finalKnobs); + collectZUpdates('knob', view.knobPositions[mode] ?? [], finalKnobs) && + collectZUpdates( + 'sprite', + view.spritePositions[mode] ?? [], + finalSprites, + ); if (!stable) { throw new ElementIntentAbort('paste source document is not canonical'); } @@ -1282,6 +1368,7 @@ export function useGridSelection({ statPositions: useStatItemStore.getState().positions as never, graphPositions: useGraphItemStore.getState().positions as never, knobPositions: useKnobItemStore.getState().positions as never, + spritePositions: useSpriteStore.getState().positions as never, layerGroups: useLayerGroupStore.getState().layerGroups as never, }, eagerElementsBefore, @@ -1296,6 +1383,7 @@ export function useGridSelection({ 'statPositions', 'graphPositions', 'knobPositions', + 'spritePositions', 'layerGroups', ], mutate: () => { @@ -1314,6 +1402,9 @@ export function useGridSelection({ useKnobItemStore .getState() .setPositions(eagerPlan.zPatch.knobPositions as never); + useSpriteStore + .getState() + .setPositions(eagerPlan.zPatch.spritePositions as never); if (eagerPlan.groupsChanged) { useLayerGroupStore .getState() @@ -1374,7 +1465,7 @@ export function useGridSelection({ // 편입 전 abort로 롤백되면 정산 뒤 pruneRolledBackPasteSelection이 정리한다 const newSelectedElements: SelectedElement[] = []; const collect = ( - type: 'key' | 'stat' | 'graph' | 'knob', + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite', record: Record>, ids: readonly string[], ) => { @@ -1406,6 +1497,11 @@ export function useGridSelection({ useKnobItemStore.getState().positions as never, knobsToAdd.map((entry) => entry.position.id), ); + collect( + 'sprite', + useSpriteStore.getState().positions as never, + spritesToAdd.map((entry) => entry.position.id), + ); const presentPluginIds = new Set( usePluginDisplayElementStore .getState() @@ -1435,6 +1531,7 @@ export function useGridSelection({ ...statsToAdd.map((entry) => entry.position.id), ...graphsToAdd.map((entry) => entry.position.id), ...knobsToAdd.map((entry) => entry.position.id), + ...spritesToAdd.map((entry) => entry.position.id), ...frozenPluginElements.map((element) => element.fullId), ]); const presentIds = new Set([ @@ -1450,6 +1547,9 @@ export function useGridSelection({ ...(useKnobItemStore.getState().positions[selectedKeyType] ?? []).map( (position) => position.id, ), + ...(useSpriteStore.getState().positions[selectedKeyType] ?? []).map( + (position) => position.id, + ), ...usePluginDisplayElementStore .getState() .elements.map((element) => element.fullId), @@ -1493,6 +1593,7 @@ export function useGridSelection({ statPositions: base.statPositions as never, graphPositions: base.graphPositions as never, knobPositions: base.knobPositions as never, + spritePositions: base.spritePositions as never, layerGroups: base.layerGroups as never, }, pluginProjection, @@ -1504,6 +1605,7 @@ export function useGridSelection({ statPositions: base.statPositions as never, graphPositions: base.graphPositions as never, knobPositions: base.knobPositions as never, + spritePositions: base.spritePositions as never, layerGroups: base.layerGroups as never, }, plan, diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index 005ee7516..f976b6c8a 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -4,6 +4,7 @@ import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useFontStore, syncFontCSS } from '@stores/useFontStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { @@ -763,6 +764,10 @@ export function useAppBootstrap() { ...state, positions: bootstrap.knobPositions ?? {}, })); + useSpriteStore.setState((state) => ({ + ...state, + positions: bootstrap.spritePositions ?? {}, + })); useLayerGroupStore .getState() .setLayerGroups(bootstrap.layerGroups ?? {}); diff --git a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts index ba8fc7fb2..04b572d22 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.idReconcile.test.ts @@ -17,6 +17,7 @@ const arraysOf = ( stat: [], graph: [], knob: [], + sprite: [], }); describe('id 기반 선택 재조정', () => { diff --git a/src/renderer/stores/grid/useGridSelectionStore.ts b/src/renderer/stores/grid/useGridSelectionStore.ts index 084055182..3a2ff88b7 100644 --- a/src/renderer/stores/grid/useGridSelectionStore.ts +++ b/src/renderer/stores/grid/useGridSelectionStore.ts @@ -4,6 +4,7 @@ import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; export type SelectableElementType = @@ -11,6 +12,7 @@ export type SelectableElementType = | 'stat' | 'graph' | 'knob' + | 'sprite' | 'plugin'; export type IndexedSelectableElementType = Exclude< @@ -24,6 +26,7 @@ export interface IndexedElementArrays { stat: readonly CanonicalEditorDocumentV1['statPositions'][string][number][]; graph: readonly CanonicalEditorDocumentV1['graphPositions'][string][number][]; knob: readonly CanonicalEditorDocumentV1['knobPositions'][string][number][]; + sprite: readonly CanonicalEditorDocumentV1['spritePositions'][string][number][]; } interface NativeSelectedElement { @@ -66,6 +69,12 @@ export interface ClipboardKnobData { position: KnobItemPosition; } +// 클립보드에 저장되는 스프라이트 데이터 +export interface ClipboardSpriteData { + type: 'sprite'; + position: ReactiveSpritePosition; +} + // 클립보드에 저장되는 플러그인 요소 데이터 export interface ClipboardPluginData { type: 'plugin'; @@ -77,6 +86,7 @@ export type ClipboardItem = | ClipboardStatData | ClipboardGraphData | ClipboardKnobData + | ClipboardSpriteData | ClipboardPluginData; interface GridSelectionState { diff --git a/src/renderer/utils/grid/groupActions.ts b/src/renderer/utils/grid/groupActions.ts index 354b8a4c5..e831dd88a 100644 --- a/src/renderer/utils/grid/groupActions.ts +++ b/src/renderer/utils/grid/groupActions.ts @@ -8,6 +8,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { selectPropertyPanelPluginElements, @@ -33,7 +34,8 @@ const splitGroupTargets = ( element.type === 'key' || element.type === 'stat' || element.type === 'graph' || - element.type === 'knob' + element.type === 'knob' || + element.type === 'sprite' ? [{ elementType: element.type, id: element.id }] : [], ); @@ -56,6 +58,12 @@ const currentNativeGroupId = ( ): string | undefined => { const locator = resolveElementById(target.elementType, target.id); if (!locator || locator.mode !== mode) return undefined; + if (target.elementType === 'sprite') { + return ( + useSpriteStore.getState().positions[mode]?.[locator.index]?.groupId ?? + undefined + ); + } const record = target.elementType === 'key' ? useKeyStore.getState().canonicalPositions diff --git a/src/renderer/utils/grid/groupSelection.test.ts b/src/renderer/utils/grid/groupSelection.test.ts index 04eab47c3..fb85a41e1 100644 --- a/src/renderer/utils/grid/groupSelection.test.ts +++ b/src/renderer/utils/grid/groupSelection.test.ts @@ -41,6 +41,7 @@ const mixedSource = ( statPositions: [{ id: STAT_ID, groupId: GROUP_ID }], graphPositions: [], knobPositions: [], + spritePositions: [], pluginElements: [pluginElement(PLUGIN_FULL_ID, GROUP_ID, '4key')], modeGroups: [{ id: GROUP_ID }], ...overrides, diff --git a/src/renderer/utils/grid/groupSelection.ts b/src/renderer/utils/grid/groupSelection.ts index 3609cb9a0..2dcf4ef25 100644 --- a/src/renderer/utils/grid/groupSelection.ts +++ b/src/renderer/utils/grid/groupSelection.ts @@ -11,11 +11,12 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; -// 그룹 판정에 필요한 native 위치 최소 형태 -type GroupablePositionLike = { id: string; groupId?: string }; +// 그룹 판정에 필요한 native 위치 최소 형태 (sprite는 groupId를 null로 저장) +type GroupablePositionLike = { id: string; groupId?: string | null }; // 그룹 판정에 필요한 플러그인 요소 최소 형태 type PluginGroupSelectableLike = Pick< @@ -29,6 +30,7 @@ export interface GroupSelectionSource { statPositions: readonly (GroupablePositionLike | null | undefined)[]; graphPositions: readonly (GroupablePositionLike | null | undefined)[]; knobPositions: readonly (GroupablePositionLike | null | undefined)[]; + spritePositions: readonly (GroupablePositionLike | null | undefined)[]; pluginElements: readonly PluginGroupSelectableLike[]; // 현재 모드의 그룹 def 목록 - def가 없는 groupId는 확장하지 않는다 modeGroups: readonly Pick[]; @@ -40,6 +42,7 @@ const nativeCollections = (source: GroupSelectionSource) => stat: source.statPositions, graph: source.graphPositions, knob: source.knobPositions, + sprite: source.spritePositions, } as const); const resolveClickedGroupId = ( @@ -50,9 +53,11 @@ const resolveClickedGroupId = ( return source.pluginElements.find((el) => el.fullId === clicked.id) ?.groupId; } - return nativeCollections(source)[clicked.type].find( - (pos) => pos?.id === clicked.id, - )?.groupId; + return ( + nativeCollections(source)[clicked.type].find( + (pos) => pos?.id === clicked.id, + )?.groupId ?? undefined + ); }; /** @@ -72,13 +77,15 @@ export const expandGroupSelection = ( } const collections = nativeCollections(source); - (['key', 'stat', 'graph', 'knob'] as const).forEach((memberType) => { - collections[memberType].forEach((pos, index) => { - if (!pos || pos.groupId !== groupId) return; - if (clicked.type === memberType && clicked.id === pos.id) return; - selection.push({ type: memberType, id: pos.id, index }); - }); - }); + (['key', 'stat', 'graph', 'knob', 'sprite'] as const).forEach( + (memberType) => { + collections[memberType].forEach((pos, index) => { + if (!pos || pos.groupId !== groupId) return; + if (clicked.type === memberType && clicked.id === pos.id) return; + selection.push({ type: memberType, id: pos.id, index }); + }); + }, + ); source.pluginElements.forEach((el) => { if (el.groupId !== groupId) return; if (!isPluginVisibleInMode(el, source.mode)) return; @@ -99,6 +106,7 @@ export const expandGroupSelectionFromStores = ( statPositions: useStatItemStore.getState().positions[mode] || [], graphPositions: useGraphItemStore.getState().positions[mode] || [], knobPositions: useKnobItemStore.getState().positions[mode] || [], + spritePositions: useSpriteStore.getState().positions[mode] || [], pluginElements: usePluginDisplayElementStore.getState().elements, modeGroups: useLayerGroupStore.getState().layerGroups[mode] || [], }); diff --git a/src/renderer/utils/layerGroupUtils.identity.test.ts b/src/renderer/utils/layerGroupUtils.identity.test.ts index 666d6d596..c14b277eb 100644 --- a/src/renderer/utils/layerGroupUtils.identity.test.ts +++ b/src/renderer/utils/layerGroupUtils.identity.test.ts @@ -26,6 +26,7 @@ describe('applyZIndexToLayerOrder stable identity', () => { {}, {}, {}, + {}, ); expect(result.keyPositions['4key']).toMatchObject([ diff --git a/src/renderer/utils/layerGroupUtils.test.ts b/src/renderer/utils/layerGroupUtils.test.ts index c2ee830e7..33627f857 100644 --- a/src/renderer/utils/layerGroupUtils.test.ts +++ b/src/renderer/utils/layerGroupUtils.test.ts @@ -10,6 +10,7 @@ const emptyPositions = { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, }; // Rust remove_empty_layer_groups와 같은 시나리오를 고정하는 교차 테스트 - diff --git a/src/renderer/utils/layerGroupUtils.ts b/src/renderer/utils/layerGroupUtils.ts index e1e25ab2f..facfd0a66 100644 --- a/src/renderer/utils/layerGroupUtils.ts +++ b/src/renderer/utils/layerGroupUtils.ts @@ -28,7 +28,7 @@ export const isPluginGroupMemberInMode = ( ): boolean => normalizePluginInstanceTabId(element.tabId) === mode; type Groupable = SelectedElement & { - type: 'key' | 'stat' | 'graph' | 'knob'; + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; }; function isGroupableElement(el: SelectedElement): el is Groupable { @@ -36,7 +36,8 @@ function isGroupableElement(el: SelectedElement): el is Groupable { el.type === 'key' || el.type === 'stat' || el.type === 'graph' || - el.type === 'knob' + el.type === 'knob' || + el.type === 'sprite' ); } @@ -47,6 +48,7 @@ function getElementGroupId( statPositions: CanonicalEditorDocumentV1['statPositions'], graphPositions: CanonicalEditorDocumentV1['graphPositions'], knobPositions: CanonicalEditorDocumentV1['knobPositions'], + spritePositions: CanonicalEditorDocumentV1['spritePositions'], ): string | undefined { if (element.type === 'key') { return keyPositions[mode]?.find(({ id }) => id === element.id)?.groupId; @@ -57,7 +59,13 @@ function getElementGroupId( if (element.type === 'graph') { return graphPositions[mode]?.find(({ id }) => id === element.id)?.groupId; } - return knobPositions[mode]?.find(({ id }) => id === element.id)?.groupId; + if (element.type === 'knob') { + return knobPositions[mode]?.find(({ id }) => id === element.id)?.groupId; + } + return ( + spritePositions[mode]?.find(({ id }) => id === element.id)?.groupId ?? + undefined + ); } function escapeRegExp(value: string): string { @@ -70,10 +78,11 @@ function collectModeGroupMemberCounts( statPositions: CanonicalEditorDocumentV1['statPositions'], graphPositions: CanonicalEditorDocumentV1['graphPositions'], knobPositions: CanonicalEditorDocumentV1['knobPositions'], + spritePositions: CanonicalEditorDocumentV1['spritePositions'], pluginElements: readonly PluginGroupMemberLike[], ): Map { const counts = new Map(); - const add = (groupId?: string) => { + const add = (groupId?: string | null) => { if (!groupId) return; counts.set(groupId, (counts.get(groupId) || 0) + 1); }; @@ -82,6 +91,7 @@ function collectModeGroupMemberCounts( (statPositions[mode] || []).forEach((pos) => add(pos?.groupId)); (graphPositions[mode] || []).forEach((pos) => add(pos?.groupId)); (knobPositions[mode] || []).forEach((pos) => add(pos?.groupId)); + (spritePositions[mode] || []).forEach((pos) => add(pos?.groupId)); // 플러그인 멤버도 그룹 생존에 기여 - 모드 판정은 저장 규칙과 동일 pluginElements.forEach((element) => { if (isPluginGroupMemberInMode(element, mode)) add(element.groupId); @@ -122,6 +132,7 @@ export function resolveSingleGroupIdFromSelection( statPositions: CanonicalEditorDocumentV1['statPositions'], graphPositions: CanonicalEditorDocumentV1['graphPositions'], knobPositions: CanonicalEditorDocumentV1['knobPositions'], + spritePositions: CanonicalEditorDocumentV1['spritePositions'], ): string | undefined { const groupIds = new Set(); @@ -134,6 +145,7 @@ export function resolveSingleGroupIdFromSelection( statPositions, graphPositions, knobPositions, + spritePositions, ); if (groupId) { groupIds.add(groupId); @@ -149,6 +161,7 @@ type ApplyGroupIdResult = { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; changed: boolean; }; @@ -159,6 +172,7 @@ export function applyGroupIdToSelectedElements(params: { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; targetGroupId: string | undefined; }): ApplyGroupIdResult { const { @@ -168,6 +182,7 @@ export function applyGroupIdToSelectedElements(params: { statPositions, graphPositions, knobPositions, + spritePositions, targetGroupId, } = params; @@ -175,16 +190,19 @@ export function applyGroupIdToSelectedElements(params: { const nextStatPositions = { ...statPositions }; const nextGraphPositions = { ...graphPositions }; const nextKnobPositions = { ...knobPositions }; + const nextSpritePositions = { ...spritePositions }; const nextKeyMode = [...(keyPositions[mode] || [])]; const nextStatMode = [...(statPositions[mode] || [])]; const nextGraphMode = [...(graphPositions[mode] || [])]; const nextKnobMode = [...(knobPositions[mode] || [])]; + const nextSpriteMode = [...(spritePositions[mode] || [])]; let changed = false; let keyChanged = false; let statChanged = false; let graphChanged = false; let knobChanged = false; + let spriteChanged = false; selectedElements.forEach((element) => { if (!isGroupableElement(element)) return; @@ -219,11 +237,22 @@ export function applyGroupIdToSelectedElements(params: { return; } - const index = nextKnobMode.findIndex(({ id }) => id === element.id); - const current = nextKnobMode[index]; - if (!current || current.groupId === targetGroupId) return; - nextKnobMode[index] = { ...current, groupId: targetGroupId }; - knobChanged = true; + if (element.type === 'knob') { + const index = nextKnobMode.findIndex(({ id }) => id === element.id); + const current = nextKnobMode[index]; + if (!current || current.groupId === targetGroupId) return; + nextKnobMode[index] = { ...current, groupId: targetGroupId }; + knobChanged = true; + changed = true; + return; + } + + const index = nextSpriteMode.findIndex(({ id }) => id === element.id); + const current = nextSpriteMode[index]; + // 스프라이트 groupId는 null 저장이라 undefined 목표와 동치 비교 + if (!current || (current.groupId ?? undefined) === targetGroupId) return; + nextSpriteMode[index] = { ...current, groupId: targetGroupId ?? null }; + spriteChanged = true; changed = true; }); @@ -231,12 +260,14 @@ export function applyGroupIdToSelectedElements(params: { if (statChanged) nextStatPositions[mode] = nextStatMode; if (graphChanged) nextGraphPositions[mode] = nextGraphMode; if (knobChanged) nextKnobPositions[mode] = nextKnobMode; + if (spriteChanged) nextSpritePositions[mode] = nextSpriteMode; return { keyPositions: changed ? nextKeyPositions : keyPositions, statPositions: changed ? nextStatPositions : statPositions, graphPositions: changed ? nextGraphPositions : graphPositions, knobPositions: changed ? nextKnobPositions : knobPositions, + spritePositions: changed ? nextSpritePositions : spritePositions, changed, }; } @@ -246,6 +277,7 @@ export interface StableElementGroupProjection { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; layerGroups: LayerGroups; changed: boolean; } @@ -258,6 +290,7 @@ export function projectStableElementGroups(params: { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; layerGroups: LayerGroups; // normalize의 플러그인 멤버 집계 - 혼합 그룹화는 최종 소속 상태를 전달 pluginElements: readonly PluginGroupMemberLike[]; @@ -270,6 +303,7 @@ export function projectStableElementGroups(params: { statPositions, graphPositions, knobPositions, + spritePositions, layerGroups, pluginElements, } = params; @@ -278,6 +312,7 @@ export function projectStableElementGroups(params: { stat: statPositions, graph: graphPositions, knob: knobPositions, + sprite: spritePositions, } as const; const selectedElements: SelectedElement[] = []; for (const target of targets) { @@ -318,6 +353,7 @@ export function projectStableElementGroups(params: { statPositions, graphPositions, knobPositions, + spritePositions, targetGroupId: targetGroup?.id, }); const targetTypes = new Set(targets.map((target) => target.elementType)); @@ -333,6 +369,9 @@ export function projectStableElementGroups(params: { knobPositions: targetTypes.has('knob') ? grouped.knobPositions : knobPositions, + spritePositions: targetTypes.has('sprite') + ? grouped.spritePositions + : spritePositions, layerGroups: nextLayerGroups, pluginElements, }); @@ -341,6 +380,7 @@ export function projectStableElementGroups(params: { statPositions: normalized.statPositions, graphPositions: normalized.graphPositions, knobPositions: normalized.knobPositions, + spritePositions: normalized.spritePositions, layerGroups: normalized.layerGroups, changed: grouped.changed || @@ -371,6 +411,7 @@ type NormalizeLayerGroupsResult = { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; layerGroups: LayerGroups; positionsChanged: boolean; groupsChanged: boolean; @@ -383,6 +424,7 @@ export function normalizeLayerGroupsForMode(params: { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; layerGroups: LayerGroups; // 플러그인 멤버 집계 - Rust remove_empty_layer_groups와 동일 규칙 필수 pluginElements: readonly PluginGroupMemberLike[]; @@ -393,6 +435,7 @@ export function normalizeLayerGroupsForMode(params: { statPositions, graphPositions, knobPositions, + spritePositions, layerGroups, pluginElements, } = params; @@ -402,10 +445,12 @@ export function normalizeLayerGroupsForMode(params: { const nextStatPositions = { ...statPositions }; const nextGraphPositions = { ...graphPositions }; const nextKnobPositions = { ...knobPositions }; + const nextSpritePositions = { ...spritePositions }; const nextKeyMode = [...(keyPositions[mode] || [])]; const nextStatMode = [...(statPositions[mode] || [])]; const nextGraphMode = [...(graphPositions[mode] || [])]; const nextKnobMode = [...(knobPositions[mode] || [])]; + const nextSpriteMode = [...(spritePositions[mode] || [])]; const initialCounts = collectModeGroupMemberCounts( mode, @@ -413,6 +458,7 @@ export function normalizeLayerGroupsForMode(params: { statPositions, graphPositions, knobPositions, + spritePositions, pluginElements, ); const groupsToDissolve = new Set(); @@ -431,7 +477,7 @@ export function normalizeLayerGroupsForMode(params: { let positionsChanged = false; if (groupsToDissolve.size > 0) { - const shouldClear = (groupId?: string) => + const shouldClear = (groupId?: string | null) => !!groupId && groupsToDissolve.has(groupId); nextKeyMode.forEach((pos, index) => { @@ -454,6 +500,11 @@ export function normalizeLayerGroupsForMode(params: { nextKnobMode[index] = { ...pos, groupId: undefined }; positionsChanged = true; }); + nextSpriteMode.forEach((pos, index) => { + if (!shouldClear(pos?.groupId)) return; + nextSpriteMode[index] = { ...pos, groupId: null }; + positionsChanged = true; + }); } if (positionsChanged) { @@ -461,6 +512,7 @@ export function normalizeLayerGroupsForMode(params: { nextStatPositions[mode] = nextStatMode; nextGraphPositions[mode] = nextGraphMode; nextKnobPositions[mode] = nextKnobMode; + nextSpritePositions[mode] = nextSpriteMode; } const finalCounts = collectModeGroupMemberCounts( @@ -469,6 +521,7 @@ export function normalizeLayerGroupsForMode(params: { positionsChanged ? nextStatPositions : statPositions, positionsChanged ? nextGraphPositions : graphPositions, positionsChanged ? nextKnobPositions : knobPositions, + positionsChanged ? nextSpritePositions : spritePositions, pluginElements, ); const nextModeGroups = currentModeGroups.filter( @@ -491,6 +544,7 @@ export function normalizeLayerGroupsForMode(params: { statPositions: positionsChanged ? nextStatPositions : statPositions, graphPositions: positionsChanged ? nextGraphPositions : graphPositions, knobPositions: positionsChanged ? nextKnobPositions : knobPositions, + spritePositions: positionsChanged ? nextSpritePositions : spritePositions, layerGroups: nextLayerGroups, positionsChanged, groupsChanged, @@ -503,20 +557,21 @@ export function normalizeLayerGroupsForMode(params: { // ============================================================================ export interface LayerItemForOrder { - type: 'key' | 'stat' | 'graph' | 'knob' | 'plugin'; + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite' | 'plugin'; id: string; index?: number; zIndex: number; groupId?: string; } -/** 5개 스토어에서 아이템을 수집하고 zIndex 내림차순 정렬 */ +/** 6개 스토어에서 아이템을 수집하고 zIndex 내림차순 정렬 */ export function buildLayerItemsForMode( mode: string, keyPositions: CanonicalEditorDocumentV1['keyPositions'], statPositions: CanonicalEditorDocumentV1['statPositions'], graphPositions: CanonicalEditorDocumentV1['graphPositions'], knobPositions: CanonicalEditorDocumentV1['knobPositions'], + spritePositions: CanonicalEditorDocumentV1['spritePositions'], pluginElements: PluginDisplayElementInternal[], ): LayerItemForOrder[] { const items: LayerItemForOrder[] = []; @@ -561,6 +616,16 @@ export function buildLayerItemsForMode( }); }); + (spritePositions[mode] || []).forEach((pos, index) => { + items.push({ + type: 'sprite', + id: pos.id, + index, + zIndex: pos.zIndex ?? index, + groupId: pos.groupId ?? undefined, + }); + }); + pluginElements .filter((el) => isPluginVisibleInMode(el, mode)) .forEach((el) => { @@ -615,6 +680,7 @@ export interface ZIndexPatchResult { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; pluginUpdates: Array<{ fullId: string; zIndex: number }>; } @@ -626,12 +692,14 @@ export function applyZIndexToLayerOrder( statPositions: CanonicalEditorDocumentV1['statPositions'], graphPositions: CanonicalEditorDocumentV1['graphPositions'], knobPositions: CanonicalEditorDocumentV1['knobPositions'], + spritePositions: CanonicalEditorDocumentV1['spritePositions'], ): ZIndexPatchResult { const maxZIndex = orderedItems.length - 1; const keyMode = [...(keyPositions[mode] || [])]; const statMode = [...(statPositions[mode] || [])]; const graphMode = [...(graphPositions[mode] || [])]; const knobMode = [...(knobPositions[mode] || [])]; + const spriteMode = [...(spritePositions[mode] || [])]; const pluginUpdates: Array<{ fullId: string; zIndex: number }> = []; orderedItems.forEach((item, idx) => { @@ -648,6 +716,9 @@ export function applyZIndexToLayerOrder( } else if (item.type === 'knob') { const index = knobMode.findIndex((position) => position.id === item.id); if (index !== -1) knobMode[index] = { ...knobMode[index], zIndex: z }; + } else if (item.type === 'sprite') { + const index = spriteMode.findIndex((position) => position.id === item.id); + if (index !== -1) spriteMode[index] = { ...spriteMode[index], zIndex: z }; } else if (item.type === 'plugin') { pluginUpdates.push({ fullId: item.id, zIndex: z }); } @@ -658,6 +729,7 @@ export function applyZIndexToLayerOrder( statPositions: { ...statPositions, [mode]: statMode }, graphPositions: { ...graphPositions, [mode]: graphMode }, knobPositions: { ...knobPositions, [mode]: knobMode }, + spritePositions: { ...spritePositions, [mode]: spriteMode }, pluginUpdates, }; } From d0218a0da0ca8fd4b0b042846e34bb26276bf41e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 30 Aug 2026 22:04:09 +0900 Subject: [PATCH 004/125] =?UTF-8?q?feat:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=B0=BD=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=A0=8C=EB=8D=94=EC=99=80=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EB=B8=94=EB=A0=8C=EB=94=A9=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/counters/OverlaySpriteItem.tsx | 128 ++++++++ .../counters/overlaySpriteItem.test.tsx | 187 ++++++++++++ .../components/shared/OverlayScene.tsx | 25 ++ .../hooks/shared/useLayoutComputation.ts | 14 + src/renderer/utils/core/zIndexFallback.ts | 2 +- .../overlay/App.spritePreview.test.tsx | 279 ++++++++++++++++++ src/renderer/windows/overlay/App.tsx | 42 ++- 7 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 src/renderer/components/overlay/counters/OverlaySpriteItem.tsx create mode 100644 src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx create mode 100644 src/renderer/windows/overlay/App.spritePreview.test.tsx diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx new file mode 100644 index 000000000..5e3856124 --- /dev/null +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -0,0 +1,128 @@ +'use no memo'; +import React, { useEffect, useMemo } from 'react'; +import { useSignals } from '@preact/signals-react/runtime'; +import { getKeySignal } from '@stores/signals/keySignals'; +import { + isErrorForCurrentSrc, + useFailedImageSrcs, +} from '@hooks/overlay/useFailedImageSrcs'; +import { resolveImageSource } from '@utils/core/imageSource'; +import { warmupImageSource } from '@utils/core/imageWarmup'; +import { resolveSpriteTarget } from '@utils/sprite/poseResolver'; +import { spriteTransformToCss } from '@src/types/key/sprites'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; + +interface OverlaySpriteItemProps { + position: CanonicalReactiveSpritePosition; + keyCanonicalMap: ReadonlyMap; +} + +// 키 입력에 따라 자세가 바뀌는 반응형 스프라이트 잎 컴포넌트. +// 담당 키 시그널을 여기서만 읽어 눌림 변화가 이 잎의 리렌더로만 남는다. +// 변환은 CSS transition이 컴포지터에서 보간하고 자세 이미지는 스냅 교체 (계약 §9) +const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ + position, + keyCanonicalMap, +}: OverlaySpriteItemProps) { + useSignals(); + + // 자세들이 참조하는 키 요소 id 전체 (중복 제거) + const triggerIds = useMemo(() => { + const seen = new Set(); + const ids: string[] = []; + for (const pose of position.poses) { + for (const trigger of pose.triggers) { + if (seen.has(trigger)) continue; + seen.add(trigger); + ids.push(trigger); + } + } + return ids; + }, [position.poses]); + + // 렌더 중 시그널 읽기로 구독 형성 - 매핑에 없는 id(죽은 레인)는 구독하지 않는다 + const pressedIds = new Set(); + for (const id of triggerIds) { + const canonical = keyCanonicalMap.get(id); + if (!canonical) continue; + if (getKeySignal(canonical).value) pressedIds.add(id); + } + + const target = resolveSpriteTarget(position, pressedIds); + + const { failedImageSrcs, markFailed } = useFailedImageSrcs( + position.baseImage, + ...position.poses.map((pose) => pose.imageOverride), + ); + + const baseImageSrc = resolveImageSource(position.baseImage); + const targetImageSrc = resolveImageSource(target.imageSrc); + + // 실패한 src는 baseImage로 폴백, 그것도 실패면 렌더 제외 + let imageSrc = targetImageSrc; + if (imageSrc && failedImageSrcs.has(imageSrc)) imageSrc = baseImageSrc; + if (imageSrc && failedImageSrcs.has(imageSrc)) imageSrc = null; + + // 첫 자세 전환에서 cold decode가 겹치지 않도록 base와 모든 override를 선행 디코드 + const warmupSrcs = useMemo(() => { + const srcs = new Set(); + const base = resolveImageSource(position.baseImage); + if (base) srcs.add(base); + for (const pose of position.poses) { + const src = resolveImageSource(pose.imageOverride); + if (src) srcs.add(src); + } + return [...srcs]; + }, [position.baseImage, position.poses]); + + useEffect(() => { + for (const src of warmupSrcs) warmupImageSource(src); + }, [warmupSrcs]); + + if (position.hidden) return null; + + return ( +
0 ? 'active' : 'idle'} + > + {imageSrc && ( + { + if (!isErrorForCurrentSrc(event.currentTarget, imageSrc)) return; + markFailed(imageSrc); + }} + /> + )} +
+ ); +}); + +export default OverlaySpriteItem; diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx new file mode 100644 index 000000000..cea225fb7 --- /dev/null +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -0,0 +1,187 @@ +/** + * 반응형 스프라이트 잎 렌더 + * - 트리거 눌림이 transform과 data-state를 바꾸고, 미눌림이면 idle을 유지한다 + * - 매핑에 없는 트리거는 무시되고, 실패한 이미지 src는 baseImage로 폴백한다 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { SpritePose } from '@src/types/key/sprites'; +import { resetAllKeySignals, setKeyActive } from '@stores/signals/keySignals'; + +import OverlaySpriteItem from './OverlaySpriteItem'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const BASE_IMAGE = 'data:image/png;base64,base'; +const OVERRIDE_IMAGE = 'data:image/png;base64,override'; + +const makePose = ( + poseId: string, + triggers: string[], + overrides: Partial = {}, +): SpritePose => ({ + poseId, + triggers, + matchMode: 'exact', + transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + ...overrides, +}); + +const makeSprite = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => ({ + id: 'sprite-1', + dx: 12, + dy: 24, + width: 300, + height: 200, + hidden: false, + zIndex: 5, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: BASE_IMAGE, + imageFit: null, + imageRect: { x: 10, y: 20, width: 120, height: 80 }, + pivot: { x: 0.5, y: 1 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [makePose('p1', ['el-a'])], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + ...overrides, +}); + +const keyCanonicalMap: ReadonlyMap = new Map([ + ['el-a', 'KeyA'], +]); + +describe('OverlaySpriteItem', () => { + let container: HTMLDivElement; + let root: Root; + + const render = ( + position: CanonicalReactiveSpritePosition, + map: ReadonlyMap = keyCanonicalMap, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + const spriteEl = () => + container.querySelector('[data-sprite-element="true"]'); + const imgEl = () => spriteEl()?.querySelector('img') ?? null; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + resetAllKeySignals(); + }); + + it('idle 상태를 idleTransform과 baseImage로 그린다', () => { + render(makeSprite()); + + const el = spriteEl(); + expect(el).not.toBeNull(); + expect(el?.dataset.state).toBe('idle'); + expect(el?.style.transform).toBe('translate3d(12px, 24px, 0)'); + expect(el?.style.width).toBe('300px'); + expect(el?.style.height).toBe('200px'); + expect(el?.style.zIndex).toBe('5'); + expect(el?.style.pointerEvents).toBe('none'); + // 시각 전용 - 히트 마커 없음 + expect(el?.hasAttribute('data-overlay-hit')).toBe(false); + + const img = imgEl(); + expect(img?.getAttribute('src')).toBe(BASE_IMAGE); + expect(img?.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(img?.style.transformOrigin).toBe('50% 100%'); + expect(img?.style.transition).toBe( + 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', + ); + }); + + it('트리거 눌림이 pose transform과 active 상태로 바꾸고, 뗌은 idle로 되돌린다', () => { + render(makeSprite()); + + act(() => setKeyActive('KeyA', true)); + expect(spriteEl()?.dataset.state).toBe('active'); + expect(imgEl()?.style.transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + + act(() => setKeyActive('KeyA', false)); + expect(spriteEl()?.dataset.state).toBe('idle'); + expect(imgEl()?.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + }); + + it('매핑에 없는 트리거는 눌림을 무시한다', () => { + render(makeSprite(), new Map()); + + act(() => setKeyActive('KeyA', true)); + expect(spriteEl()?.dataset.state).toBe('idle'); + }); + + it('imageOverride pose는 눌림에서 이미지를 교체한다', () => { + render( + makeSprite({ + poses: [makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE })], + }), + ); + + expect(imgEl()?.getAttribute('src')).toBe(BASE_IMAGE); + act(() => setKeyActive('KeyA', true)); + expect(imgEl()?.getAttribute('src')).toBe(OVERRIDE_IMAGE); + }); + + it('실패한 override src는 baseImage로 폴백하고, base도 실패하면 img를 내리지 않는다', () => { + render( + makeSprite({ + poses: [makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE })], + }), + ); + + act(() => setKeyActive('KeyA', true)); + const img = imgEl(); + expect(img?.getAttribute('src')).toBe(OVERRIDE_IMAGE); + + act(() => { + img?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()?.getAttribute('src')).toBe(BASE_IMAGE); + + act(() => { + imgEl()?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()).toBeNull(); + // 이미지가 없어도 요소 자체는 남는다 + expect(spriteEl()?.dataset.state).toBe('active'); + }); + + it('hidden이면 아무것도 그리지 않는다', () => { + render(makeSprite({ hidden: true })); + expect(spriteEl()).toBeNull(); + }); +}); diff --git a/src/renderer/components/shared/OverlayScene.tsx b/src/renderer/components/shared/OverlayScene.tsx index 4c752c8ed..b972222f2 100644 --- a/src/renderer/components/shared/OverlayScene.tsx +++ b/src/renderer/components/shared/OverlayScene.tsx @@ -6,6 +6,7 @@ import StatItem from '@components/overlay/counters/StatItem'; import StatCounterLayer from '@components/overlay/counters/StatCounterLayer'; import OverlayGraphItemBase from '@components/overlay/counters/OverlayGraphItem'; import OverlayKnobItemBase from '@components/overlay/counters/OverlayKnobItem'; +import OverlaySpriteItem from '@components/overlay/counters/OverlaySpriteItem'; import { PluginElementsRenderer } from '@components/shared/PluginElementsRenderer'; import { getKeyInfoByGlobalKey } from '@utils/core/KeyMaps'; import { type KeyPosition } from '@src/types/key/keys'; @@ -67,6 +68,13 @@ const Tracks = lazy(async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any type NoteSubscriber = (event: any) => void; +// 미지정 시 identity 안정 기본값 +const EMPTY_SPRITE_POSITIONS: CanonicalEditorDocumentV1['spritePositions'][string] = + Object.freeze( + [], + ) as unknown as CanonicalEditorDocumentV1['spritePositions'][string]; +const EMPTY_SPRITE_KEY_MAP: ReadonlyMap = new Map(); + interface OverlaySceneProps { // 키/위치 데이터 (currentKeys = canonical 문자열) currentKeys: string[]; @@ -77,6 +85,9 @@ interface OverlaySceneProps { displayStatPositions: CanonicalEditorDocumentV1['statPositions'][string]; displayGraphPositions: CanonicalEditorDocumentV1['graphPositions'][string]; displayKnobPositions: CanonicalEditorDocumentV1['knobPositions'][string]; + displaySpritePositions?: CanonicalEditorDocumentV1['spritePositions'][string]; + // 스프라이트 트리거(키 요소 id) -> canonical 키 문자열 + spriteKeyCanonicalMap?: ReadonlyMap; selectedKeyType: string; // 노트 이펙트 @@ -114,6 +125,8 @@ const OverlayScene = ({ displayStatPositions, displayGraphPositions, displayKnobPositions, + displaySpritePositions = EMPTY_SPRITE_POSITIONS, + spriteKeyCanonicalMap = EMPTY_SPRITE_KEY_MAP, selectedKeyType, noteEffect, noteSettings, @@ -249,6 +262,18 @@ const OverlayScene = ({ ); })} + {displaySpritePositions.map((pos, index) => { + if (!pos || pos.hidden) return null; + // 전개 금지 - 원본 identity를 보존해야 잎의 React.memo가 유지된다 + const position = resolveZIndexFallback(pos, index); + return ( + + ); + })} {keyCounterEnabled ? ( 0 || currentGraphPositions.length > 0 || currentKnobPositions.length > 0 || + currentSpritePositions.length > 0 || (pluginElements && pluginElements.length > 0); if (!hasContent) return null; @@ -123,6 +126,15 @@ export function computeLayout(input: LayoutInput) { heights.push(pos.dy + (pos.height ?? 80)); }); + // 스프라이트 활동 영역 전체를 창 크기에 포함, 클리핑하지 않는다 (계약 §9) + currentSpritePositions.forEach((pos) => { + if (!pos || pos.hidden) return; + xs.push(pos.dx); + ys.push(pos.dy); + widths.push(pos.dx + (pos.width ?? 200)); + heights.push(pos.dy + (pos.height ?? 200)); + }); + // 플러그인 요소 위치 (앵커 기반 계산 포함) if (pluginElements && selectedKeyType) { pluginElements @@ -201,6 +213,7 @@ export function computeLayout(input: LayoutInput) { const displayStatPositions = applyOffset(currentStatPositions); const displayGraphPositions = applyOffset(currentGraphPositions); const displayKnobPositions = applyOffset(currentKnobPositions); + const displaySpritePositions = applyOffset(currentSpritePositions); const positionOffset = bounds ? { x: offsetX, y: offsetY } : { x: 0, y: 0 }; @@ -273,6 +286,7 @@ export function computeLayout(input: LayoutInput) { displayStatPositions, displayGraphPositions, displayKnobPositions, + displaySpritePositions, positionOffset, // 창 높이·배경 박스가 같은 값을 쓰도록 노출 (창 == 콘텐츠 박스 불변식) topOffset, diff --git a/src/renderer/utils/core/zIndexFallback.ts b/src/renderer/utils/core/zIndexFallback.ts index a2fd6915b..f67a56fa1 100644 --- a/src/renderer/utils/core/zIndexFallback.ts +++ b/src/renderer/utils/core/zIndexFallback.ts @@ -4,7 +4,7 @@ const cache = new WeakMap(); // zIndex가 이미 있으면 원본을 그대로 돌려준다. 무조건 펼치면 새 객체가 되어 // 아래 키 컴포넌트의 memo가 항상 깨지고 키 하나 변경에 전부 다시 그려진다. // 백엔드가 미설정 zIndex를 null로 직렬화하므로 결측 판정은 undefined만이 아니라 null까지 본다 -export const resolveZIndexFallback = ( +export const resolveZIndexFallback = ( base: T, index: number, ): T => { diff --git a/src/renderer/windows/overlay/App.spritePreview.test.tsx b/src/renderer/windows/overlay/App.spritePreview.test.tsx new file mode 100644 index 000000000..89d4282a7 --- /dev/null +++ b/src/renderer/windows/overlay/App.spritePreview.test.tsx @@ -0,0 +1,279 @@ +// 오버레이 창의 스프라이트 프리뷰 합성 계약 +// 메인 캔버스 드래그·스크럽의 원격 patch가 커밋 전에도 레이아웃 입력에 +// 반영되고, cancel·커밋 정산 후에는 canonical 참조가 그대로 돌아와야 한다 +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { resetAllKeySignals } from '@stores/signals/keySignals'; +import { previewOverlay } from '@src/renderer/editor/runtime/previewOverlay'; + +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { PreviewEnvelope } from '@src/types/preview'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + bootstrap: vi.fn(), + layoutInputs: [] as Array<{ + currentSpritePositions: ReadonlyArray<{ id?: string | null; dx: number }>; + }>, + sceneSpritePositions: [] as Array< + ReadonlyArray<{ id?: string | null; dx: number }> + >, +})); + +vi.mock('@tauri-apps/api/window', () => ({ + currentMonitor: vi.fn(), + getCurrentWindow: () => ({ + startDragging: vi.fn(() => Promise.resolve()), + }), + Window: { getByLabel: vi.fn() }, +})); +vi.mock('@tauri-apps/api/dpi', () => ({ + LogicalPosition: class LogicalPosition {}, + PhysicalPosition: class PhysicalPosition {}, +})); +vi.mock('@tauri-apps/api/menu', () => ({ + Menu: { new: vi.fn() }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@hooks/app/useCustomCssInjection', () => ({ + useCustomCssInjection: vi.fn(), +})); +vi.mock('@hooks/app/useCustomJsInjection', () => ({ + useCustomJsInjection: vi.fn(), +})); +vi.mock('@hooks/app/useBlockBrowserShortcuts', () => ({ + useBlockBrowserShortcuts: vi.fn(), +})); +vi.mock('@hooks/app/useAppBootstrap', () => ({ useAppBootstrap: vi.fn() })); +vi.mock('@hooks/overlay/useBuiltinStatsSubscription', () => ({ + useBuiltinStatsSubscription: vi.fn(), +})); +vi.mock('@hooks/overlay/useNoteSystem', () => ({ + useNoteSystem: () => ({ + notesRef: { current: {} }, + subscribe: () => () => {}, + handleKeyDown: vi.fn(), + handleKeyUp: vi.fn(), + finalizeAllActive: vi.fn(), + reconcileActiveNotes: vi.fn(), + noteBuffer: {}, + updateTrackLayouts: vi.fn(), + }), +})); +vi.mock('@stores/data/useStatItemStore', () => ({ + useStatItemStore: ( + selector: (state: { positions: Record }) => T, + ) => selector({ positions: {} }), +})); +vi.mock('@stores/data/useGraphItemStore', () => ({ + useGraphItemStore: ( + selector: (state: { positions: Record }) => T, + ) => selector({ positions: {} }), +})); +vi.mock('@stores/data/useKnobItemStore', () => ({ + useKnobItemStore: ( + selector: (state: { positions: Record }) => T, + ) => selector({ positions: {} }), +})); +vi.mock('@hooks/overlay/useOverlayHitRegions', () => ({ + useOverlayHitRegions: () => {}, + subscribeHitContextMenu: () => () => {}, +})); +vi.mock('@components/shared/OverlayScene', () => ({ + default: (props: { + displaySpritePositions: ReadonlyArray<{ id?: string | null; dx: number }>; + }) => { + mocks.sceneSpritePositions.push(props.displaySpritePositions); + return null; + }, +})); +vi.mock('@hooks/shared/useLayoutComputation', () => ({ + // 합성된 sprite 입력을 그대로 통과시켜 OverlayScene까지의 배선을 관찰 + computeLayout: (input: { + currentSpritePositions: ReadonlyArray<{ id?: string | null; dx: number }>; + }) => { + mocks.layoutInputs.push(input); + return { + bounds: null, + displayPositions: [], + displayStatPositions: [], + displayGraphPositions: [], + displayKnobPositions: [], + displaySpritePositions: input.currentSpritePositions, + positionOffset: { x: 0, y: 0 }, + topOffset: 0, + webglTracks: [], + }; + }, +})); +vi.mock('@utils/core/axisEventBus', () => ({ + axisEventBus: { initialize: vi.fn() }, +})); +vi.mock('@utils/core/keyEventBus', () => ({ + keyEventBus: { + subscribe: vi.fn(() => vi.fn()), + initialize: vi.fn(() => Promise.resolve()), + }, +})); +vi.mock('@api/modules/obsApi', () => ({ + obsApi: { + onResync: vi.fn(() => vi.fn()), + }, +})); + +import App from './App'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; + +const SPRITE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const SESSION_ID = '00000000-0000-4000-8000-0000000000aa'; + +const spritePosition = (dx: number): CanonicalReactiveSpritePosition => ({ + id: SPRITE_ID, + dx, + dy: 25, + width: 200, + height: 120, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'linear', +}); + +const remoteEnvelope = ( + overrides: Partial = {}, +): PreviewEnvelope => ({ + schemaVersion: 1, + sessionId: SESSION_ID, + seq: 1, + kind: 'patch', + sourceLabel: 'main', + domain: 'spritePosition', + mode: '4key', + targets: [0], + patch: { dx: 99 }, + ...overrides, +}); + +const lastLayoutSprites = () => + mocks.layoutInputs[mocks.layoutInputs.length - 1].currentSpritePositions; + +const lastSceneSprites = () => + mocks.sceneSpritePositions[mocks.sceneSpritePositions.length - 1]; + +const flushAsync = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + +describe('overlay sprite preview composition', () => { + let container: HTMLDivElement; + let root: Root; + let originalApi: Window['api']; + + beforeEach(async () => { + originalApi = window.api; + mocks.bootstrap.mockReset(); + mocks.bootstrap.mockResolvedValue({ activeKeys: [] }); + mocks.layoutInputs.length = 0; + mocks.sceneSpritePositions.length = 0; + previewOverlay.clearAll(); + window.api = { + app: { bootstrap: mocks.bootstrap }, + keys: { onKeysReset: vi.fn(() => vi.fn()) }, + } as unknown as Window['api']; + usePluginDisplayElementStore.setState({ elements: [] }); + useKeyStore.setState({ + selectedKeyType: '4key', + customTabs: [], + keyMappings: { '4key': ['KeyK'] }, + positions: { '4key': [] }, + canonicalPositions: { '4key': [] }, + isBootstrapped: true, + isLocalUpdateInProgress: false, + }); + useSpriteStore.setState({ positions: { '4key': [spritePosition(15)] } }); + resetAllKeySignals(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render(); + }); + await flushAsync(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + previewOverlay.clearAll(); + resetAllKeySignals(); + window.api = originalApi; + vi.restoreAllMocks(); + }); + + it('원격 patch는 canonical을 건드리지 않고 레이아웃·씬 입력에만 합성된다', () => { + expect(lastLayoutSprites()[0].dx).toBe(15); + + act(() => { + previewOverlay.applyRemoteEnvelope(remoteEnvelope()); + }); + + expect(lastLayoutSprites()[0].dx).toBe(99); + expect(lastSceneSprites()[0].dx).toBe(99); + // canonical 불가침 + expect(useSpriteStore.getState().positions['4key'][0].dx).toBe(15); + }); + + it('cancel은 합성을 걷어내고 canonical 참조를 그대로 돌려준다', () => { + act(() => { + previewOverlay.applyRemoteEnvelope(remoteEnvelope()); + }); + expect(lastLayoutSprites()[0].dx).toBe(99); + + act(() => { + previewOverlay.applyRemoteEnvelope( + remoteEnvelope({ kind: 'cancel', seq: 2 }), + ); + }); + + expect(lastLayoutSprites()[0].dx).toBe(15); + // 프리뷰가 없으면 identity 보존 - layout memo 재계산 없음 + expect(lastLayoutSprites()).toBe( + useSpriteStore.getState().positions['4key'], + ); + }); + + it('커밋 정산 후에는 갱신된 canonical을 프리뷰 없이 반영한다', async () => { + act(() => { + previewOverlay.applyRemoteEnvelope(remoteEnvelope()); + }); + + // 커밋 정산: canonical 확정 반영 + 세션 종료 + const committed = { '4key': [spritePosition(99)] }; + await act(async () => { + useSpriteStore.setState({ positions: committed }); + previewOverlay.endSession(SESSION_ID); + }); + + expect(lastLayoutSprites()[0].dx).toBe(99); + expect(lastLayoutSprites()).toBe(committed['4key']); + expect(lastSceneSprites()).toBe(committed['4key']); + }); +}); diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index 01cf6de6f..f6683d04a 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; import { currentMonitor, getCurrentWindow, @@ -30,6 +36,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { setKeyActive as setKeyActiveSignal, resetAllKeySignals, @@ -49,6 +56,12 @@ import { slotCanonical, slotDisplayName, } from '@utils/keySlot'; +import { buildSpriteKeyCanonicalMap } from '@utils/sprite/spriteKeyBinding'; +import { + composePreviewPositions, + getPreviewOverlayVersion, + subscribePreviewOverlay, +} from '@src/renderer/editor/runtime/previewOverlay'; import type { KeySlot } from '@src/types/key/keys'; type KeyDelayTimerHandle = ReturnType; @@ -142,6 +155,7 @@ export default function App() { const statPositions = useStatItemStore((state) => state.positions); const graphPositions = useGraphItemStore((state) => state.positions); const knobPositions = useKnobItemStore((state) => state.positions); + const spritePositions = useSpriteStore((state) => state.positions); // 레이아웃 필드만 투영 구독 - 플러그인 state/html 변경이 App 리렌더로 승격되지 않게 const pluginElements = useStoreWithEqualityFn( usePluginDisplayElementStore, @@ -770,6 +784,27 @@ export default function App() { const currentStatPositions = statPositions[selectedKeyType] ?? EMPTY_SLICE; const currentGraphPositions = graphPositions[selectedKeyType] ?? EMPTY_SLICE; const currentKnobPositions = knobPositions[selectedKeyType] ?? EMPTY_SLICE; + // 키는 useKeyStore.positions가 이미 프리뷰 합성본이지만 sprite는 canonical만 + // 온다 - 세션 patch를 렌더 시점에 합성해야 메인 캔버스 드래그·스크럽이 + // 커밋 전에도 오버레이에 반영된다. 프리뷰가 없으면 canonical 참조가 그대로 + // 돌아와 layout memo가 재계산되지 않는다 + useSyncExternalStore( + subscribePreviewOverlay, + getPreviewOverlayVersion, + getPreviewOverlayVersion, + ); + const renderedSpritePositions = composePreviewPositions( + 'spritePosition', + spritePositions, + ); + const currentSpritePositions = + renderedSpritePositions[selectedKeyType] ?? EMPTY_SLICE; + + // 스프라이트 트리거(키 요소 id) -> canonical 키 문자열, 잎이 시그널을 찾을 때 쓴다 + const spriteKeyCanonicalMap = useMemo( + () => buildSpriteKeyCanonicalMap(currentSlots, currentPositions), + [currentSlots, currentPositions], + ); // 레이아웃 입력이 실제로 바뀔 때만 재계산 - webglTracks identity가 안정되어 // updateTrackLayouts effect·WebGL uniform effect가 무관한 리렌더에 재실행되지 않음 @@ -781,6 +816,7 @@ export default function App() { currentStatPositions, currentGraphPositions, currentKnobPositions, + currentSpritePositions, trackHeight, noteSettings, selectedKeyType, @@ -793,6 +829,7 @@ export default function App() { currentStatPositions, currentGraphPositions, currentKnobPositions, + currentSpritePositions, trackHeight, noteSettings, selectedKeyType, @@ -806,6 +843,7 @@ export default function App() { displayStatPositions, displayGraphPositions, displayKnobPositions, + displaySpritePositions, positionOffset, topOffset, webglTracks, @@ -928,6 +966,8 @@ export default function App() { displayStatPositions={displayStatPositions} displayGraphPositions={displayGraphPositions} displayKnobPositions={displayKnobPositions} + displaySpritePositions={displaySpritePositions} + spriteKeyCanonicalMap={spriteKeyCanonicalMap} selectedKeyType={selectedKeyType} noteEffect={noteEffect} contentSize={contentSize} From 5fd2f6fe48e4ea92ecc450ed50385ed72926b6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 30 Aug 2026 22:04:09 +0900 Subject: [PATCH 005/125] =?UTF-8?q?feat:=20=EC=97=90=EB=94=94=ED=84=B0=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B0=B0=EC=B9=98=C2=B7=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=ED=9D=90=EB=A6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 284 ++++++++++++++-- .../components/main/Grid/core/GridMinimap.tsx | 23 ++ .../main/Grid/handles/GroupResizeHandles.tsx | 5 + .../main/Grid/handles/groupResizeUtils.ts | 18 + .../Grid/layers/SpriteItem.render.test.tsx | 122 +++++++ .../main/Grid/layers/SpriteItem.tsx | 307 ++++++++++++++++++ .../components/main/Tool/CanvasTool.tsx | 5 +- src/renderer/components/main/Tool/ToolBar.tsx | 2 +- .../hooks/Grid/useGridCanvasActions.test.tsx | 102 +++++- .../hooks/Grid/useGridCanvasActions.ts | 101 +++++- .../Grid/useGridContextMenu.gridMenu.test.tsx | 2 + .../useGridContextMenu.pluginGroup.test.tsx | 9 +- src/renderer/hooks/Grid/useGridContextMenu.ts | 10 + src/renderer/hooks/Grid/useGridMarquee.ts | 22 ++ .../hooks/Grid/useGridResize.test.tsx | 92 ++++++ src/renderer/hooks/Grid/useGridResize.ts | 11 +- .../hooks/Grid/useSmartGuidesElements.ts | 14 + src/renderer/windows/main/App.tsx | 2 +- 18 files changed, 1088 insertions(+), 43 deletions(-) create mode 100644 src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx create mode 100644 src/renderer/components/main/Grid/layers/SpriteItem.tsx diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index f22427090..ac1017d04 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -33,6 +33,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { PluginElementsRenderer } from '@components/shared/PluginElementsRenderer'; @@ -60,6 +61,7 @@ import KeyCounterPreviewLayer from '../layers/KeyCounterPreviewLayer'; import StatCounterLayer from '../layers/StatCounterLayer'; import GraphItem from '../layers/GraphItem'; import KnobItem from '../layers/KnobItem'; +import SpriteItem from '../layers/SpriteItem'; import { useGridSelectionStore, isElementInMarquee, @@ -93,6 +95,10 @@ import { DEFAULT_IMAGE_MODE, imageTransformToCss, } from '@src/types/key/imageLayer'; +import { + spriteTransformToCss, + type ReactiveSpritePosition, +} from '@src/types/key/sprites'; import { DEFAULT_ELEMENT_BG, DEFAULT_ELEMENT_FONT, @@ -133,7 +139,7 @@ import { type ToolbarAddRequest = { id: number; - type: 'key' | 'stat' | 'graph' | 'knob'; + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; } | null; interface GridProps { @@ -235,6 +241,7 @@ const Grid = ({ getStatMenuItems, getGraphMenuItems, getKnobMenuItems, + getSpriteMenuItems, getGridMenuItems, pluginKeyMenuItems, pluginGridMenuItems, @@ -322,6 +329,7 @@ const Grid = ({ const canonicalStatPositions = useStatItemStore((state) => state.positions); const canonicalGraphPositions = useGraphItemStore((state) => state.positions); const canonicalKnobPositions = useKnobItemStore((state) => state.positions); + const canonicalSpritePositions = useSpriteStore((state) => state.positions); useSyncExternalStore( subscribePreviewOverlay, getPreviewOverlayVersion, @@ -339,6 +347,10 @@ const Grid = ({ 'knobPosition', canonicalKnobPositions, ); + const spritePositions = composePreviewPositions( + 'spritePosition', + canonicalSpritePositions, + ); // 선택 관련 로직 훅 사용 const { @@ -369,6 +381,7 @@ const Grid = ({ statPositions, graphPositions, knobPositions, + spritePositions, selectedKeyType, pluginElements, clientToGridCoords, @@ -451,6 +464,7 @@ const Grid = ({ const statRefs = useRef<(HTMLElement | null)[]>([]); const graphRefs = useRef<(HTMLElement | null)[]>([]); const knobRefs = useRef<(HTMLElement | null)[]>([]); + const spriteRefs = useRef<(HTMLElement | null)[]>([]); const gridRef = useRef(null); const [duplicateState, setDuplicateState] = useState( null, @@ -475,6 +489,8 @@ const Grid = ({ useGraphItemStore.getState().positions[selectedKeyType] || []; const modeKnobPos = useKnobItemStore.getState().positions[selectedKeyType] || []; + const modeSpritePos = + useSpriteStore.getState().positions[selectedKeyType] || []; let anyInGroup = false; let allInSameGroup = true; @@ -496,6 +512,10 @@ const Grid = ({ gid = modeGraphPos.find((position) => position.id === el.id)?.groupId; } else if (el.type === 'knob') { gid = modeKnobPos.find((position) => position.id === el.id)?.groupId; + } else if (el.type === 'sprite') { + gid = + modeSpritePos.find((position) => position.id === el.id)?.groupId ?? + undefined; } else if (el.type === 'plugin') { // 플러그인 소속도 그룹 메뉴 판정에 포함 - 모드 def가 있는 것만 유효 const pluginGroupId = usePluginDisplayElementStore @@ -585,6 +605,13 @@ const Grid = ({ setDuplicateCursor(null); }; + const beginDuplicateSprite = (sourceIndex: number) => { + const result = canvasActions.beginDuplicateSprite(sourceIndex); + if (!result) return; + setDuplicateState(result); + setDuplicateCursor(null); + }; + const duplicateSelectedFromContextMenu = async () => { copySelectedElements(); await pasteElements().catch(reportElementOpError); @@ -594,11 +621,15 @@ const Grid = ({ const isStableNativeSelection = (el: { type: string; id: string; - }): el is { type: 'key' | 'stat' | 'graph' | 'knob'; id: string } => + }): el is { + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; + id: string; + } => (el.type === 'key' || el.type === 'stat' || el.type === 'graph' || - el.type === 'knob') && + el.type === 'knob' || + el.type === 'sprite') && el.id.length > 0 && isNativeElementId(el.id); @@ -615,7 +646,13 @@ const Grid = ({ mode: selectedKeyType, targets: orderStableZTargetsForBatch( selectedElements.map((element) => ({ - type: element.type as 'key' | 'stat' | 'graph' | 'knob' | 'plugin', + type: element.type as + | 'key' + | 'stat' + | 'graph' + | 'knob' + | 'sprite' + | 'plugin', id: element.id, })), ), @@ -640,7 +677,13 @@ const Grid = ({ mode: selectedKeyType, targets: orderStableZTargetsForBatch( selectedElements.map((element) => ({ - type: element.type as 'key' | 'stat' | 'graph' | 'knob' | 'plugin', + type: element.type as + | 'key' + | 'stat' + | 'graph' + | 'knob' + | 'sprite' + | 'plugin', id: element.id, })), ), @@ -679,6 +722,8 @@ const Grid = ({ const defaultSize = toolbarAddRequest.type === 'graph' ? { width: 120, height: 60 } + : toolbarAddRequest.type === 'sprite' + ? { width: 200, height: 200 } : { width: 60, height: 60 }; const targetPos = getViewportCenterSnappedPosition( defaultSize.width, @@ -748,7 +793,7 @@ const Grid = ({ // 요소 클릭 시 그룹 멤버 자동 선택 const selectElementWithGroup = ( - type: 'key' | 'stat' | 'graph' | 'knob', + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite', index: number, ) => { if (isContextOpen) { @@ -760,10 +805,11 @@ const Grid = ({ stat: useStatItemStore.getState().positions[selectedKeyType] || [], graph: useGraphItemStore.getState().positions[selectedKeyType] || [], knob: useKnobItemStore.getState().positions[selectedKeyType] || [], + sprite: useSpriteStore.getState().positions[selectedKeyType] || [], } as const; const clicked = collections[type][index]; - // stat·graph·knob은 렌더 클로저가 아니라 live 스토어를 읽는다. 렌더와 + // stat·graph·knob·sprite는 렌더 클로저가 아니라 live 스토어를 읽는다. 렌더와 // 클릭 사이에 배열이 줄면 대상이 없을 수 있어 fail-closed로 닫는다 if (!clicked) { reportElementOpSkipped('group selection (target missing)'); @@ -778,6 +824,7 @@ const Grid = ({ statPositions: collections.stat, graphPositions: collections.graph, knobPositions: collections.knob, + spritePositions: collections.sprite, pluginElements: usePluginDisplayElementStore.getState().elements, modeGroups: useLayerGroupStore.getState().layerGroups[selectedKeyType] || [], @@ -790,7 +837,7 @@ const Grid = ({ // 더블클릭 편집 진입 — 대상이 다중 선택의 멤버면 선택을 보존해 배치 편집으로, // 아니면 해당 요소(+그룹)만 선택해 단일 편집으로 property 페이지를 연다 const openElementEditor = ( - type: 'key' | 'stat' | 'graph' | 'knob', + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite', index: number, ) => { const { selectedElements: currentSelection } = @@ -802,6 +849,8 @@ const Grid = ({ ? statPositions[selectedKeyType] : type === 'graph' ? graphPositions[selectedKeyType] + : type === 'sprite' + ? spritePositions[selectedKeyType] : knobPositions[selectedKeyType]; const targetId = positionsForType?.[index]?.id; const isMultiMember = @@ -816,7 +865,7 @@ const Grid = ({ // 요소 컨텍스트 메뉴 열기 const openElementContextMenu = ( - type: 'key' | 'stat' | 'graph' | 'knob', + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite', index: number, clientX: number, clientY: number, @@ -833,6 +882,8 @@ const Grid = ({ ? useStatItemStore.getState().positions[selectedKeyType]?.[index] : type === 'graph' ? useGraphItemStore.getState().positions[selectedKeyType]?.[index] + : type === 'sprite' + ? useSpriteStore.getState().positions[selectedKeyType]?.[index] : useKnobItemStore.getState().positions[selectedKeyType]?.[index]; // 조회가 옵셔널인데 여기서 무방비로 풀면, 렌더와 우클릭 사이에 배열이 줄었을 때 // TypeError가 나고 에러 바운더리가 없어 앱이 통째로 언마운트된다. @@ -1071,6 +1122,24 @@ const Grid = ({ } }); + // 범위 내 스프라이트 요소도 선택 + (spritePositions?.[selectedKeyType] || []).forEach((pos, i) => { + if (!pos || pos.hidden) return; + const elementBounds = { + x: pos.dx, + y: pos.dy, + width: pos.width || 200, + height: pos.height || 200, + }; + if (isElementInMarquee(elementBounds, rangeRect)) { + newSelectedElements.push({ + type: 'sprite', + id: pos.id, + index: i, + }); + } + }); + setSelectedElements(newSelectedElements); }, onMultiDrag: (deltaX: number, deltaY: number) => @@ -1362,6 +1431,80 @@ const Grid = ({ )); }; + const renderSpriteItems = () => { + const items = spritePositions?.[selectedKeyType] || []; + if (!items.length) return null; + + const handleSpritePositionChange = ( + index: number, + dx: number, + dy: number, + elementId: string, + ) => { + commitElementPosition('sprite', elementId, dx, dy); + }; + + return items.map((position, index) => ( + { + selectElementWithGroup('sprite', index); + }} + onDoubleClick={() => openElementEditor('sprite', index)} + onCtrlClick={() => { + toggleSelection({ + type: 'sprite', + id: useSpriteStore.getState().positions[selectedKeyType][index].id, + index, + }); + }} + onShiftClick={() => { + toggleSelection({ + type: 'sprite', + id: useSpriteStore.getState().positions[selectedKeyType][index].id, + index, + }); + }} + isSelected={selectedElements.some( + (el) => el.type === 'sprite' && el.id === position.id, + )} + selectedElements={selectedElements} + onMultiDrag={(deltaX, deltaY) => + moveSelectedElements(deltaX, deltaY, undefined, false) + } + onMultiDragStart={beginSelectedPluginInstancesDrag} + onMultiDragEnd={commitSelectedElementsDrag} + activeTool={activeTool} + onEraserClick={() => { + void deleteElementById('sprite', position.id).catch( + reportElementOpError, + ); + }} + onContextMenu={(e) => { + openElementContextMenu( + 'sprite', + index, + e.clientX, + e.clientY, + spriteRefs.current[index] || null, + ); + }} + zoom={zoom} + panX={panX} + panY={panY} + isViewportTransforming={isTransforming} + setReferenceRef={(node) => { + spriteRefs.current[index] = node; + }} + /> + )); + }; + // 고스트는 실제 요소와 같은 기본 립을 그린다. 링 배경은 전역 규칙이 아닌 // 인라인으로 - 고스트는 data-*-element가 아니라 전역 게이트를 안 탄다 const renderGhostBorderRing = (suppressDefault: boolean) => { @@ -1382,6 +1525,52 @@ const Grid = ({ const renderDuplicateGhost = () => { if (!duplicateState || !duplicateCursor) return null; + if (duplicateState.elementType === 'sprite') { + const spritePosition = duplicateState.position as ReactiveSpritePosition; + const width = spritePosition.width || 200; + const height = spritePosition.height || 200; + const offsetX = duplicateCursor.x - width / 2; + const offsetY = duplicateCursor.y - height / 2; + const ghostImage = resolveImageSource(spritePosition.baseImage); + return ( +
+ {ghostImage && ( + + )} +
+ ); + } + if (duplicateState.elementType === 'graph') { const width = duplicateState.position?.width || 200; const height = duplicateState.position?.height || 100; @@ -1407,22 +1596,24 @@ const Grid = ({ ); } + // sprite는 위에서 분기 완료 - 남은 타입은 키형 공통 필드를 공유한다 const { - position: { - width = 60, - height = 60, - inactiveImage, - activeImage, - imageFit, - idleImageFit, - imageMode, - idleImageTransform, - className, - shadow, - activeShadow, - }, - keyName, - } = duplicateState; + width = 60, + height = 60, + inactiveImage, + activeImage, + imageFit, + idleImageFit, + imageMode, + idleImageTransform, + className, + shadow, + activeShadow, + } = duplicateState.position as Exclude< + DuplicateState['position'], + ReactiveSpritePosition + >; + const { keyName } = duplicateState; const previewImage = resolveImageSource(inactiveImage) || resolveImageSource(activeImage) || @@ -1612,6 +1803,7 @@ const Grid = ({ {renderStatItems()} {renderGraphItems()} {renderKnobItems()} + {renderSpriteItems()} {/* Outside 카운터 미리보기 레이어 */} {keyCounterEnabled && ( { @@ -1870,7 +2068,7 @@ const Grid = ({ // 메뉴가 열린 동안의 재정렬·삭제를 액션 시점에 재해석. // 모드 밖으로 이동한 대상은 소실로 취급한다 const resolveContextTarget = ( - targetType: 'key' | 'stat' | 'graph' | 'knob', + targetType: 'key' | 'stat' | 'graph' | 'knob' | 'sprite', ): number | null => { if (!contextElementId) return null; const locator = resolveElementById(targetType, contextElementId); @@ -1947,6 +2145,39 @@ const Grid = ({ return; } + if (contextType === 'sprite') { + const spriteIndex = resolveContextTarget('sprite'); + if (id === 'delete') { + if (contextElementId) { + void deleteElementById('sprite', contextElementId).catch( + reportElementOpError, + ); + } + } else if (id === 'duplicate') { + if (spriteIndex != null) beginDuplicateSprite(spriteIndex); + } else if (id === 'bringToFront') { + if (contextElementId) { + void commitStableLayerZOrder({ + mode: selectedKeyType, + targets: [{ type: 'sprite', id: contextElementId }], + action: 'front', + }).catch(reportElementOpError); + } + } else if (id === 'sendToBack') { + if (contextElementId) { + void commitStableLayerZOrder({ + mode: selectedKeyType, + targets: [{ type: 'sprite', id: contextElementId }], + action: 'back', + }).catch(reportElementOpError); + } + } + + setIsContextOpen(false); + setContextPosition(null); + return; + } + if (contextType === 'knob') { const knobIndex = resolveContextTarget('knob'); if (id === 'delete') { @@ -2233,6 +2464,8 @@ const Grid = ({ ? 'graph' : id === 'addKnob' ? 'knob' + : id === 'addSprite' + ? 'sprite' : null; if (addType && gridAddLocalPos) { addCanvasElementAt( @@ -2268,6 +2501,7 @@ const Grid = ({ statPositions={statPositions?.[selectedKeyType] || []} graphPositions={graphPositions?.[selectedKeyType] || []} knobPositions={knobPositions?.[selectedKeyType] || []} + spritePositions={spritePositions?.[selectedKeyType] || []} zoom={zoom} panX={panX} panY={panY} diff --git a/src/renderer/components/main/Grid/core/GridMinimap.tsx b/src/renderer/components/main/Grid/core/GridMinimap.tsx index 7a6d100f0..32f569dbe 100644 --- a/src/renderer/components/main/Grid/core/GridMinimap.tsx +++ b/src/renderer/components/main/Grid/core/GridMinimap.tsx @@ -31,6 +31,7 @@ interface GridMinimapProps { statPositions?: Position[]; graphPositions?: Position[]; knobPositions?: Position[]; + spritePositions?: Position[]; zoom: number; panX: number; panY: number; @@ -87,6 +88,7 @@ const GridMinimap = ({ statPositions = [], graphPositions = [], knobPositions = [], + spritePositions = [], zoom, panX, panY, @@ -197,6 +199,7 @@ const GridMinimap = ({ statPositions.length === 0 && graphPositions.length === 0 && knobPositions.length === 0 && + spritePositions.length === 0 && pluginPositions.length === 0 ) { return { minX: 0, minY: 0, maxX: 100, maxY: 100 }; @@ -263,6 +266,20 @@ const GridMinimap = ({ maxY = Math.max(maxY, y + h); }); + // 스프라이트 요소 bounds + spritePositions.forEach((pos) => { + if (pos.hidden) return; + const x = pos.dx || 0; + const y = pos.dy || 0; + const w = pos.width || 200; + const h = pos.height || 200; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + }); + // 플러그인 요소들의 바운딩 박스 계산 pluginPositions.forEach((pos) => { minX = Math.min(minX, pos.x); @@ -404,6 +421,7 @@ const GridMinimap = ({ statPositions.length === 0 && graphPositions.length === 0 && knobPositions.length === 0 && + spritePositions.length === 0 && pluginPositions.length === 0 ) { return null; @@ -572,6 +590,11 @@ const GridMinimap = ({ })} {/* 노브 요소 표시 */} {renderElementRects('knob', knobPositions, { round: true })} + {/* 스프라이트 요소 표시 */} + {renderElementRects('sprite', spritePositions, { + defaultW: 200, + defaultH: 200, + })} {/* 플러그인 요소 표시 */} {renderElementRects( 'plugin', diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index 616cb368d..8dd914532 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -67,6 +67,8 @@ interface GroupResizeHandlesProps { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + // Grid 배선 전에도 컴포넌트가 동작하도록 선택 prop - 미전달 시 스프라이트만 제외 + spritePositions?: CanonicalEditorDocumentV1['spritePositions']; selectedKeyType: string; pluginElements: PluginDisplayElementInternal[]; zoom?: number; @@ -266,6 +268,7 @@ const GroupResizeHandles = ({ statPositions, graphPositions, knobPositions, + spritePositions, selectedKeyType, pluginElements, zoom = 1, @@ -303,6 +306,7 @@ const GroupResizeHandles = ({ knobPositions, selectedKeyType, pluginElements, + spritePositions, ); // 각 요소가 리사이즈 가능한지 확인 @@ -863,6 +867,7 @@ const GroupResizeHandles = ({ knobPositions, selectedKeyType, pluginElements, + spritePositions, ); if (!bounds) return null; diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts index e6b6e0cc3..481802f24 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts @@ -63,6 +63,9 @@ export function isElementResizable( return true; } else if (element.type === 'knob') { return true; + } else if (element.type === 'sprite') { + // 리사이즈는 활동 영역 박스만 조정한다 - imageRect·poses는 불변 + return true; } else if (element.type === 'plugin') { const pluginEl = pluginElements.find( (p: PluginDisplayElementInternal) => p.fullId === element.id, @@ -90,6 +93,7 @@ export function getElementBounds( knobPositions: CanonicalEditorDocumentV1['knobPositions'], selectedKeyType: string, pluginElements: PluginDisplayElementInternal[], + spritePositions?: CanonicalEditorDocumentV1['spritePositions'], ): Bounds | null { if (element.type === 'key') { const pos = positions[selectedKeyType]?.find( @@ -135,6 +139,18 @@ export function getElementBounds( width: pos.width || 60, height: pos.height || 60, }; + } else if (element.type === 'sprite') { + // 활동 영역 박스가 곧 리사이즈 대상 + const pos = spritePositions?.[selectedKeyType]?.find( + (candidate) => candidate.id === element.id, + ); + if (!pos) return null; + return { + x: pos.dx, + y: pos.dy, + width: pos.width || 60, + height: pos.height || 60, + }; } else if (element.type === 'plugin') { const pluginEl = pluginElements.find( (p: PluginDisplayElementInternal) => p.fullId === element.id, @@ -161,6 +177,7 @@ export function calculateGroupBounds( knobPositions: CanonicalEditorDocumentV1['knobPositions'], selectedKeyType: string, pluginElements: PluginDisplayElementInternal[], + spritePositions?: CanonicalEditorDocumentV1['spritePositions'], ): (Bounds & { elementBounds: ElementBounds[] }) | null { let minX = Infinity; let minY = Infinity; @@ -192,6 +209,7 @@ export function calculateGroupBounds( knobPositions, selectedKeyType, pluginElements, + spritePositions, ); if (!bounds) continue; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx new file mode 100644 index 000000000..a95b16ed0 --- /dev/null +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -0,0 +1,122 @@ +/** + * SpriteItem 캔버스 렌더 계약 테스트 + * 활동 영역 테두리는 선택 시 선택 색으로 바뀌고, hidden이면 렌더하지 않는다 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import SpriteItem from '@components/main/Grid/layers/SpriteItem'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('@utils/core/platform', () => ({ isMac: () => false })); + +vi.mock('@hooks/Grid', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: false, + isDragging: false, + recentPressMovedRef: { current: false }, + }), + }; +}); + +vi.mock('@hooks/Grid/useSelectionDrag', () => ({ + useSelectionDrag: () => ({ + handlePointerDown: () => {}, + movedDuringPressRef: { current: false }, + pressMovedRef: { current: false }, + }), +})); + +const SPRITE_ID = '00000000-0000-4000-8000-000000000801'; + +const spritePosition = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => ({ + id: SPRITE_ID, + dx: 10, + dy: 20, + width: 200, + height: 200, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: 'contain', + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + ...overrides, +}); + +let container: HTMLDivElement; +let root: Root; + +const renderSprite = ( + position: CanonicalReactiveSpritePosition, + options: { isSelected?: boolean } = {}, +) => { + act(() => { + root.render( + {}} + isSelected={options.isSelected ?? false} + activeTool="move" + />, + ); + }); + return container.querySelector('[data-sprite-element="true"]'); +}; + +describe('SpriteItem 렌더', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('선택 여부에 따라 활동 영역 테두리가 가이드와 선택 색을 오간다', () => { + const idle = renderSprite(spritePosition()); + expect(idle).not.toBeNull(); + expect(idle?.style.border).toContain('dashed'); + expect(idle?.dataset.selected).toBeUndefined(); + // 이미지가 없으면 자리표시자 렌더 + expect( + idle?.querySelector('[data-sprite-placeholder="true"]'), + ).not.toBeNull(); + + const selected = renderSprite(spritePosition(), { isSelected: true }); + expect(selected?.style.border).toContain('var(--ui-selection-border)'); + expect(selected?.dataset.selected).toBe('true'); + }); + + it('hidden 스프라이트는 렌더하지 않는다', () => { + const node = renderSprite(spritePosition({ hidden: true })); + expect(node).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx new file mode 100644 index 000000000..a8261d9ef --- /dev/null +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -0,0 +1,307 @@ +import React from 'react'; +import { isMac } from '@utils/core/platform'; +import { useDraggable, useSmartGuidesElements } from '@hooks/Grid'; +import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; +import { useSettingsStore } from '@stores/useSettingsStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { resolveImageSource } from '@utils/core/imageSource'; +import { spriteTransformToCss } from '@src/types/key/sprites'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; + +interface SelectedElement { + id: string; + type?: string; + index?: number; +} + +interface SpriteItemProps { + index: number; + elementId: string; + position: CanonicalReactiveSpritePosition; + onPositionChange: ( + index: number, + dx: number, + dy: number, + elementId: string, + ) => void; + onClick?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; + onCtrlClick?: (e: React.MouseEvent) => void; + onShiftClick?: (e: React.MouseEvent) => void; + isSelected?: boolean; + selectedElements?: SelectedElement[]; + onMultiDrag?: (dx: number, dy: number) => void; + onMultiDragStart?: () => void | (() => void); + onMultiDragEnd?: () => void; + activeTool?: string; + onEraserClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; + setReferenceRef?: (node: HTMLElement | null) => void; + zoom?: number; + panX?: number; + panY?: number; + zIndex?: number; + isViewportTransforming?: boolean; +} + +// 활동 영역은 에디터 전용 가이드 - 오버레이에는 그리지 않는다 +const ACTIVITY_AREA_BORDER = '1px dashed rgba(237, 238, 242, 0.4)'; +const SELECTED_AREA_BORDER = '1px solid var(--ui-selection-border)'; + +// 캔버스의 스프라이트는 정적이다: idle transform 상태만 그린다. +// 키 눌림 라이브 반응은 오버레이 창(OverlaySpriteItem) 몫 +const SpriteItem = ({ + index, + elementId, + position, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, + isSelected = false, + selectedElements = [], + onMultiDrag, + onMultiDragStart, + onMultiDragEnd, + activeTool, + onEraserClick, + onContextMenu, + setReferenceRef, + zoom = 1, + panX = 0, + panY = 0, + zIndex = 0, + isViewportTransforming = false, +}: SpriteItemProps) => { + const macOS = isMac(); + const { dx = 0, dy = 0, width = 200, height = 200, className } = position; + + const { getOtherElements } = useSmartGuidesElements(); + const gridSnapSize = useSettingsStore( + (state: { gridSettings?: { gridSnapSize?: number } }) => + state.gridSettings?.gridSnapSize ?? 5, + ); + const isDraggingOrResizing = useGridSelectionStore( + (state: { isDraggingOrResizing: boolean }) => state.isDraggingOrResizing, + ); + + const isSelectionMode = isSelected; + + const imageSrc = resolveImageSource(position.baseImage); + + const draggable = useDraggable({ + gridSize: gridSnapSize, + initialX: dx, + initialY: dy, + onPositionChange: (newDx: number, newDy: number) => { + if (!isSelectionMode) { + // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 + onPositionChange(index, newDx, newDy, elementId); + } + }, + zoom, + panX, + panY, + elementId, + elementWidth: width || 200, + elementHeight: height || 200, + getOtherElements, + disabled: isSelectionMode, + }); + + const { + handlePointerDown: handleSelectionDragPointerDown, + movedDuringPressRef, + pressMovedRef, + } = useSelectionDrag({ + enabled: isSelectionMode, + zoom, + startX: dx, + startY: dy, + elementId, + elementWidth: width || 200, + elementHeight: height || 200, + selectedElements, + getOtherElements, + onMultiDragStart, + onMultiDrag, + onMultiDragEnd, + }); + + if (position.hidden) return null; + + const handleClick = (e: React.MouseEvent) => { + // macOS ctrl+클릭은 우클릭 제스처 - contextmenu 뒤에 오는 click이 + // 선택·패널 오픈으로 이어져 방금 연 메뉴를 닫는 것을 차단 + if (macOS && e.ctrlKey) return; + // 드래그로 끝난 press의 trailing click 흡수 (KnobItem과 동일 규칙) + if (draggable.wasMoved || pressMovedRef.current) { + e.stopPropagation(); + return; + } + const isPrimaryModifierPressed = macOS ? e.metaKey : e.ctrlKey; + const isShiftPressed = e.shiftKey; + + if (isSelectionMode && isPrimaryModifierPressed && onCtrlClick) { + e.stopPropagation(); + onCtrlClick(e); + return; + } + + if (isSelectionMode) { + e.stopPropagation(); + return; + } + + if (activeTool === 'eraser') { + onEraserClick?.(); + return; + } + + if (!draggable.wasMoved) { + if (isShiftPressed && onShiftClick) { + e.stopPropagation(); + onShiftClick(e); + return; + } + if (isPrimaryModifierPressed && onCtrlClick) { + e.stopPropagation(); + onCtrlClick(e); + return; + } + onClick?.(e); + } + }; + + // 더블클릭 편집 진입 - 순수 더블클릭만 통과 + const handleDoubleClick = (e: React.MouseEvent) => { + if (!onDoubleClick) return; + if (macOS && e.ctrlKey) return; + if (e.shiftKey || e.metaKey || e.ctrlKey) return; + if (activeTool === 'eraser') return; + if (isViewportTransforming) return; + if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) + return; + e.stopPropagation(); + onDoubleClick(e); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e); + }; + + const attachRef = (node: HTMLElement | null) => { + if (!isSelectionMode) { + draggable.ref(node); + } + if (typeof setReferenceRef === 'function') { + setReferenceRef(node); + } + }; + + const transform = `translate(calc(${draggable.dx}px + var(--key-offset-x, 0px)), calc(${draggable.dy}px + var(--key-offset-y, 0px)))`; + + return ( +
e.preventDefault()} + > +
+ {imageSrc ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +}; + +export default SpriteItem; diff --git a/src/renderer/components/main/Tool/CanvasTool.tsx b/src/renderer/components/main/Tool/CanvasTool.tsx index 9cecc4511..e9d9488fb 100644 --- a/src/renderer/components/main/Tool/CanvasTool.tsx +++ b/src/renderer/components/main/Tool/CanvasTool.tsx @@ -14,7 +14,7 @@ import { useIconMotion } from '@hooks/useIconMotion'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; type SelectableTool = 'move' | 'eraser'; -type AddItemType = 'key' | 'stat' | 'graph' | 'knob'; +type AddItemType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; interface CanvasToolProps { onAddItem: (type: AddItemType) => void; @@ -183,6 +183,7 @@ const CanvasTool = ({ { id: 'addStat', label: t('toolbar.addStat') }, { id: 'addGraph', label: t('toolbar.addGraph') }, { id: 'addKnob', label: t('toolbar.addKnob') }, + { id: 'addSprite', label: t('toolbar.addSprite') }, ]} onSelect={(id) => { if (id === 'addKey') { @@ -193,6 +194,8 @@ const CanvasTool = ({ onAddItem('graph'); } else if (id === 'addKnob') { onAddItem('knob'); + } else if (id === 'addSprite') { + onAddItem('sprite'); } setIsAddPopupOpen(false); }} diff --git a/src/renderer/components/main/Tool/ToolBar.tsx b/src/renderer/components/main/Tool/ToolBar.tsx index 51adfd583..99eb43830 100644 --- a/src/renderer/components/main/Tool/ToolBar.tsx +++ b/src/renderer/components/main/Tool/ToolBar.tsx @@ -15,7 +15,7 @@ import { appApi } from '@api/modules/appApi'; import { useModalLayerActive } from '../Modal/popupLayer'; interface ToolBarProps { - onAddItem: (type: 'key' | 'stat' | 'graph' | 'knob') => void; + onAddItem: (type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite') => void; onTogglePalette: () => void; onClosePalette: () => void; isPaletteOpen: boolean; diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx index 4f6267159..9688155c6 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx +++ b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx @@ -15,6 +15,9 @@ const core = vi.hoisted(() => ({ addKnobAt: vi.fn((_mode: string, _position: unknown) => Promise.resolve(true), ), + addSpriteAt: vi.fn((_mode: string, _position: unknown) => + Promise.resolve(true), + ), placeDuplicatedStat: vi.fn( (..._args: [string, unknown, number, number, number]) => Promise.resolve(true), @@ -30,6 +33,10 @@ const core = vi.hoisted(() => ({ (..._args: [string, unknown, number, number, number]) => Promise.resolve(true), ), + placeDuplicatedSprite: vi.fn( + (..._args: [string, unknown, number, number, number]) => + Promise.resolve(true), + ), })); vi.mock('@src/renderer/editor/runtime/elementOps', () => core); @@ -44,8 +51,16 @@ import { import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { + CENTER_SPRITE_ANCHOR, + DEFAULT_SPRITE_TRANSITION_EASING, + DEFAULT_SPRITE_TRANSITION_MS, + IDENTITY_SPRITE_TRANSFORM, + type ReactiveSpritePosition, +} from '@src/types/key/sprites'; const stat = (id: string) => ({ ...createDefaultKeyPosition(), @@ -68,6 +83,29 @@ const knob = (id: string) => ({ reverse: false, }); +const sprite = (id: string): ReactiveSpritePosition & { id: string } => ({ + id, + dx: 0, + dy: 0, + width: 200, + height: 200, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: 'contain', + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: { ...CENTER_SPRITE_ANCHOR }, + idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, + poses: [], + activation: 'whileHeld', + transitionMs: DEFAULT_SPRITE_TRANSITION_MS, + transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, +}); + describe('useGridCanvasActions create와 ghost duplicate', () => { let container: HTMLDivElement; let root: Root; @@ -92,6 +130,7 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { useStatItemStore.setState({ positions: { '4key': [] } }); useGraphItemStore.setState({ positions: { '4key': [] } }); useKnobItemStore.setState({ positions: { '4key': [] } }); + useSpriteStore.setState({ positions: { '4key': [] } }); usePluginDisplayElementStore.setState({ elements: [ { @@ -148,6 +187,42 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { } }); + it('addSpriteAtPosition은 기본값 계약과 id 선발급을 고정한다', () => { + act(() => { + actions.addSpriteAtPosition(11, 22); + }); + + expect(core.addSpriteAt).toHaveBeenCalledTimes(1); + const [mode, payload] = core.addSpriteAt.mock.calls[0] as [ + string, + ReactiveSpritePosition & { id: string }, + ]; + expect(mode).toBe('4key'); + expect(typeof payload.id).toBe('string'); + expect(payload.id.length).toBeGreaterThan(0); + expect(payload).toMatchObject({ + dx: 11, + dy: 22, + width: 200, + height: 200, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: 'contain', + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: CENTER_SPRITE_ANCHOR, + idleTransform: IDENTITY_SPRITE_TRANSFORM, + poses: [], + activation: 'whileHeld', + transitionMs: DEFAULT_SPRITE_TRANSITION_MS, + transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, + }); + }); + it('key ghost place는 동결 slot과 payload를 index 재조회 없이 전달한다', () => { const source = { slot: { main: 'A', modifiers: ['Shift'] }, @@ -164,10 +239,10 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { ); }); - it('Grid 공용 add routing은 네 타입을 정확히 한 handler로 보낸다', () => { + it('Grid 공용 add routing은 다섯 타입을 정확히 한 handler로 보낸다', () => { act(() => { - (['key', 'stat', 'graph', 'knob'] as const).forEach((type, index) => - addCanvasElementAt(actions, type, index, index + 10), + (['key', 'stat', 'graph', 'knob', 'sprite'] as const).forEach( + (type, index) => addCanvasElementAt(actions, type, index, index + 10), ); }); @@ -175,9 +250,10 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { expect(core.addStatAt).toHaveBeenCalledTimes(1); expect(core.addGraphAt).toHaveBeenCalledTimes(1); expect(core.addKnobAt).toHaveBeenCalledTimes(1); + expect(core.addSpriteAt).toHaveBeenCalledTimes(1); }); - it('Grid 공용 ghost routing은 네 타입과 key slot 누락 fail-closed를 고정한다', () => { + it('Grid 공용 ghost routing은 다섯 타입과 key slot 누락 fail-closed를 고정한다', () => { const cases = [ { elementType: 'key' as const, @@ -204,6 +280,12 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { keyName: 'Knob', position: knob('knob-0'), }, + { + elementType: 'sprite' as const, + sourceIndex: 0, + keyName: 'Sprite', + position: sprite('sprite-0'), + }, ]; act(() => @@ -216,20 +298,23 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { expect(core.placeDuplicatedStat).toHaveBeenCalledTimes(1); expect(core.placeDuplicatedGraph).toHaveBeenCalledTimes(1); expect(core.placeDuplicatedKnob).toHaveBeenCalledTimes(1); + expect(core.placeDuplicatedSprite).toHaveBeenCalledTimes(1); expect( placeFrozenDuplicateAt(actions, { ...cases[0], slot: undefined }, 3, 4), ).toBe(false); expect(core.placeDuplicatedKey).toHaveBeenCalledTimes(1); }); - it('ghost place 3종은 synthetic source도 허용하고 클릭 시 maxZ+1을 동결한다', () => { + it('ghost place 4종은 synthetic source도 허용하고 클릭 시 maxZ+1을 동결한다', () => { const sourceStat = stat('stat-0'); const sourceGraph = graph('graph-0'); const sourceKnob = knob('knob-0'); + const sourceSprite = sprite('sprite-0'); act(() => { actions.placeDuplicateStat(sourceStat, 1.25, 2.75); actions.placeDuplicateGraph(sourceGraph, 3.25, 4.75); actions.placeDuplicateKnob(sourceKnob, 5.25, 6.75); + actions.placeDuplicateSprite(sourceSprite, 7.25, 8.75); }); expect(core.placeDuplicatedStat).toHaveBeenCalledWith( @@ -253,5 +338,12 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { 6.75, 41, ); + expect(core.placeDuplicatedSprite).toHaveBeenCalledWith( + '4key', + sourceSprite, + 7.25, + 8.75, + 41, + ); }); }); diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.ts b/src/renderer/hooks/Grid/useGridCanvasActions.ts index 64ee07785..84ccd8068 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.ts +++ b/src/renderer/hooks/Grid/useGridCanvasActions.ts @@ -8,10 +8,12 @@ import { addKeyAt, addGraphAt, addKnobAt, + addSpriteAt, addStatAt, placeDuplicatedGraph, placeDuplicatedKey, placeDuplicatedKnob, + placeDuplicatedSprite, placeDuplicatedStat, } from '@src/renderer/editor/runtime/elementOps'; import type { FrozenKeyDuplicate } from '@src/renderer/editor/runtime/elementOps'; @@ -20,11 +22,19 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import type { KeySlot, KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import { + CENTER_SPRITE_ANCHOR, + DEFAULT_SPRITE_TRANSITION_EASING, + DEFAULT_SPRITE_TRANSITION_MS, + IDENTITY_SPRITE_TRANSFORM, + type ReactiveSpritePosition, +} from '@src/types/key/sprites'; import type { KeyCounterSettings, CounterAnimationBezier, @@ -51,6 +61,9 @@ function collectAllZIndexes(mode: string) { const knobPos = useKnobItemStore.getState().positions[mode] || []; const knobZIndexes = knobPos.map((p, i) => p.zIndex ?? i); + const spritePos = useSpriteStore.getState().positions[mode] || []; + const spriteZIndexes = spritePos.map((p, i) => p.zIndex ?? i); + const pluginEls = usePluginDisplayElementStore.getState().elements; const pluginZIndexes = pluginEls .filter((el) => !el.tabId || el.tabId === mode) @@ -61,6 +74,7 @@ function collectAllZIndexes(mode: string) { statZIndexes, graphZIndexes, knobZIndexes, + spriteZIndexes, pluginZIndexes, }; } @@ -71,6 +85,7 @@ function getMaxZIndex(mode: string): number { statZIndexes, graphZIndexes, knobZIndexes, + spriteZIndexes, pluginZIndexes, } = collectAllZIndexes(mode); return Math.max( @@ -79,6 +94,7 @@ function getMaxZIndex(mode: string): number { ...statZIndexes, ...graphZIndexes, ...knobZIndexes, + ...spriteZIndexes, ...pluginZIndexes, ); } @@ -114,10 +130,18 @@ export interface CanvasActions { dx: number, dy: number, ) => void; + // Sprite 액션 + addSpriteAtPosition: (dx: number, dy: number) => void; + beginDuplicateSprite: (sourceIndex: number) => DuplicateState | null; + placeDuplicateSprite: ( + templatePosition: ReactiveSpritePosition, + dx: number, + dy: number, + ) => void; } export interface DuplicateState { - elementType: 'key' | 'stat' | 'graph' | 'knob'; + elementType: 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; sourceIndex: number; // 키 복제의 시작 시점 동결 슬롯 - 배치 시 sourceIndex 재조회 금지 slot?: KeySlot; @@ -126,10 +150,16 @@ export interface DuplicateState { | KeyPosition | StatItemPosition | GraphItemPosition - | KnobItemPosition; + | KnobItemPosition + | ReactiveSpritePosition; } -export type NativeCanvasElementType = 'key' | 'stat' | 'graph' | 'knob'; +export type NativeCanvasElementType = + | 'key' + | 'stat' + | 'graph' + | 'knob' + | 'sprite'; export const addCanvasElementAt = ( actions: Pick< @@ -138,6 +168,7 @@ export const addCanvasElementAt = ( | 'addStatAtPosition' | 'addGraphAtPosition' | 'addKnobAtPosition' + | 'addSpriteAtPosition' >, type: NativeCanvasElementType, dx: number, @@ -146,6 +177,7 @@ export const addCanvasElementAt = ( if (type === 'key') actions.addKeyAtPosition(dx, dy); else if (type === 'stat') actions.addStatAtPosition(dx, dy); else if (type === 'graph') actions.addGraphAtPosition(dx, dy); + else if (type === 'sprite') actions.addSpriteAtPosition(dx, dy); else actions.addKnobAtPosition(dx, dy); }; @@ -156,6 +188,7 @@ export const placeFrozenDuplicateAt = ( | 'placeDuplicateStat' | 'placeDuplicateGraph' | 'placeDuplicateKnob' + | 'placeDuplicateSprite' >, duplicate: DuplicateState, dx: number, @@ -176,6 +209,12 @@ export const placeFrozenDuplicateAt = ( dx, dy, ); + } else if (duplicate.elementType === 'sprite') { + actions.placeDuplicateSprite( + duplicate.position as ReactiveSpritePosition, + dx, + dy, + ); } else { actions.placeDuplicateKnob(duplicate.position as KnobItemPosition, dx, dy); } @@ -425,6 +464,59 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { void addKnobAt(selectedKeyType, position).catch(reportElementOpError); }; + const beginDuplicateSprite = (sourceIndex: number): DuplicateState | null => { + const current = useSpriteStore.getState().positions; + const position = current?.[selectedKeyType]?.[sourceIndex] || null; + if (!position) return null; + return { + elementType: 'sprite', + sourceIndex, + keyName: 'Sprite', + position: { ...position }, + }; + }; + + const placeDuplicateSprite = ( + templatePosition: ReactiveSpritePosition, + dx: number, + dy: number, + ) => { + const maxZ = getMaxZIndex(selectedKeyType); + void placeDuplicatedSprite( + selectedKeyType, + templatePosition, + dx, + dy, + maxZ + 1, + ).catch(reportElementOpError); + }; + + const addSpriteAtPosition = (dx: number, dy: number) => { + const position: ReactiveSpritePosition & { id: string } = { + id: newElementId(), + dx, + dy, + width: 200, + height: 200, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: 'contain', + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: { ...CENTER_SPRITE_ANCHOR }, + idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, + poses: [], + activation: 'whileHeld', + transitionMs: DEFAULT_SPRITE_TRANSITION_MS, + transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, + }; + void addSpriteAt(selectedKeyType, position).catch(reportElementOpError); + }; + return { addKeyAtPosition, placeDuplicateKey, @@ -437,5 +529,8 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { addKnobAtPosition, beginDuplicateKnob, placeDuplicateKnob, + addSpriteAtPosition, + beginDuplicateSprite, + placeDuplicateSprite, }; } diff --git a/src/renderer/hooks/Grid/useGridContextMenu.gridMenu.test.tsx b/src/renderer/hooks/Grid/useGridContextMenu.gridMenu.test.tsx index 662bb91f3..c2c6a01ab 100644 --- a/src/renderer/hooks/Grid/useGridContextMenu.gridMenu.test.tsx +++ b/src/renderer/hooks/Grid/useGridContextMenu.gridMenu.test.tsx @@ -122,6 +122,7 @@ describe('그리드 컨텍스트 메뉴', () => { 'addStat', 'addGraph', 'addKnob', + 'addSprite', 'separator-1', 'tabGroup', 'resetGroup', @@ -148,6 +149,7 @@ describe('그리드 컨텍스트 메뉴', () => { 'addStat', 'addGraph', 'addKnob', + 'addSprite', 'separator-1', 'tabGroup', 'resetGroup', diff --git a/src/renderer/hooks/Grid/useGridContextMenu.pluginGroup.test.tsx b/src/renderer/hooks/Grid/useGridContextMenu.pluginGroup.test.tsx index fd124dbca..0f135ceb2 100644 --- a/src/renderer/hooks/Grid/useGridContextMenu.pluginGroup.test.tsx +++ b/src/renderer/hooks/Grid/useGridContextMenu.pluginGroup.test.tsx @@ -133,13 +133,14 @@ describe('빈 바닥 메뉴 플러그인 묶음', () => { 'addStat', 'addGraph', 'addKnob', + 'addSprite', 'separator-1', PLUGIN_GROUP_ID, 'tabGroup', 'resetGroup', ]); - expect(captured.grid[5].label).toBe('contextMenu.plugins'); - expect(captured.grid[5].children).toHaveLength(4); + expect(captured.grid[6].label).toBe('contextMenu.plugins'); + expect(captured.grid[6].children).toHaveLength(4); }); it('position을 무시하고 등록 순서와 fullId를 그대로 넘긴다', () => { @@ -189,8 +190,8 @@ describe('빈 바닥 메뉴 플러그인 묶음', () => { const group = captured.grid.find((entry) => entry.id === PLUGIN_GROUP_ID); expect(group?.children?.map((child) => child.id)).toEqual(['demo:sane']); - // 추가 4개 + 구분선 1개 + 묶음 3개 - expect(captured.grid).toHaveLength(8); + // 추가 5개 + 구분선 1개 + 묶음 3개 + expect(captured.grid).toHaveLength(9); expect(error).toHaveBeenCalled(); }); diff --git a/src/renderer/hooks/Grid/useGridContextMenu.ts b/src/renderer/hooks/Grid/useGridContextMenu.ts index 7c9f78a79..221be5989 100644 --- a/src/renderer/hooks/Grid/useGridContextMenu.ts +++ b/src/renderer/hooks/Grid/useGridContextMenu.ts @@ -82,6 +82,7 @@ interface UseGridContextMenuReturn { getStatMenuItems: (contextIndex: number | null) => MenuItem[]; getGraphMenuItems: (contextIndex: number | null) => MenuItem[]; getKnobMenuItems: (contextIndex: number | null) => MenuItem[]; + getSpriteMenuItems: (contextIndex: number | null) => MenuItem[]; getGridMenuItems: ( gridAddLocalPos: { dx: number; dy: number } | null, ) => MenuEntry[]; @@ -276,6 +277,13 @@ export function useGridContextMenu({ { id: 'sendToBack', label: t('contextMenu.sendToBack') }, ]; + const getSpriteMenuItems = (_contextIndex: number | null): MenuItem[] => [ + { id: 'delete', label: t('contextMenu.deleteSprite') }, + { id: 'duplicate', label: t('contextMenu.duplicateSprite') }, + { id: 'bringToFront', label: t('contextMenu.bringToFront') }, + { id: 'sendToBack', label: t('contextMenu.sendToBack') }, + ]; + const getGridMenuItems = ( gridAddLocalPos: { dx: number; dy: number } | null, ): MenuEntry[] => { @@ -284,6 +292,7 @@ export function useGridContextMenu({ { id: 'addStat', label: t('contextMenu.addStat') }, { id: 'addGraph', label: t('contextMenu.addGraph') }, { id: 'addKnob', label: t('contextMenu.addKnob') }, + { id: 'addSprite', label: t('contextMenu.addSprite') }, ]; // 묶음 안에서는 부모가 맥락을 주므로 라벨을 줄인다 const tabItems: MenuItem[] = [ @@ -346,6 +355,7 @@ export function useGridContextMenu({ getStatMenuItems, getGraphMenuItems, getKnobMenuItems, + getSpriteMenuItems, getGridMenuItems, pluginKeyMenuItems, pluginGridMenuItems, diff --git a/src/renderer/hooks/Grid/useGridMarquee.ts b/src/renderer/hooks/Grid/useGridMarquee.ts index 65acca475..205a5e36d 100644 --- a/src/renderer/hooks/Grid/useGridMarquee.ts +++ b/src/renderer/hooks/Grid/useGridMarquee.ts @@ -19,6 +19,8 @@ interface UseGridMarqueeParams { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + // Grid 배선 전에도 훅이 동작하도록 선택 인자 - 미전달 시 스프라이트만 제외 + spritePositions?: CanonicalEditorDocumentV1['spritePositions']; selectedKeyType: string; pluginElements: PluginDisplayElementInternal[]; clientToGridCoords: ( @@ -46,6 +48,7 @@ export function useGridMarquee({ statPositions, graphPositions, knobPositions, + spritePositions = {}, selectedKeyType, pluginElements, clientToGridCoords, @@ -217,6 +220,25 @@ export function useGridMarquee({ } }); + // 스프라이트 요소 체크 + const sprites = spritePositions[selectedKeyType] || []; + sprites.forEach((pos, index) => { + if (!pos || pos.hidden) return; + const elementBounds = { + x: pos.dx, + y: pos.dy, + width: pos.width || 60, + height: pos.height || 60, + }; + if (isElementInMarquee(elementBounds, rect)) { + newSelectedElements.push({ + type: 'sprite', + id: pos.id, + index, + }); + } + }); + // 플러그인 요소 체크 (현재 탭에 속하는 것만) pluginElements.forEach((el) => { if (el.hidden) return; diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index 7629187fa..e34654d45 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -3,7 +3,9 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { EDITOR_OPS_VERSION } from '@src/types/editor'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import type { ElementBounds } from '@utils/grid/smartGuides'; import { useGridResize } from './useGridResize'; @@ -169,6 +171,38 @@ const keySelection = (): SelectedElement => ({ const STABLE_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const STABLE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const STABLE_SPRITE = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + +const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ + id, + dx: 0, + dy: 0, + width: 200, + height: 120, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + poseId: 'pose-1', + triggers: [STABLE_A], + matchMode: 'exact', + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'linear', +}); const stableKeySelection = (id: string, index = 0): SelectedElement => ({ id, @@ -223,6 +257,7 @@ describe('useGridResize plugin gesture lifecycle', () => { mocks.cancelMixedGesture.mockClear(); mocks.elements = []; mocks.keyPositions = [{ dx: 0, dy: 0, width: 40, height: 40 }]; + useSpriteStore.setState({ positions: {} }); mocks.gridSettings = { alignmentGuides: false, spacingGuides: false, @@ -384,6 +419,63 @@ describe('useGridResize plugin gesture lifecycle', () => { ); }); + it('그룹 resize의 sprite는 활동 영역 박스만 커밋하고 imageRect·poses는 불변이다', async () => { + const original = spriteAt(STABLE_SPRITE); + useSpriteStore.setState({ positions: { '4key': [original] } }); + mocks.elements = [{ fullId: 'plugin-a:one', pluginId: 'plugin-a' }]; + const spriteSelected: SelectedElement = { + id: STABLE_SPRITE, + type: 'sprite', + index: 0, + }; + const selected = [spriteSelected, pluginSelection('plugin-a:one')]; + await renderHarness(selected); + + await act(async () => { + api.handleResizeStart(); + api.handleGroupResize({ + groupBounds: { x: 10, y: 20, width: 200, height: 80 }, + elementBounds: [ + { + element: spriteSelected, + bounds: { x: 10, y: 20, width: 100, height: 60 }, + }, + { + element: selected[1], + bounds: { x: 120, y: 20, width: 80, height: 60 }, + }, + ], + handle: { id: 'e', dx: 1, dy: 0 }, + }); + api.handleGroupResizeComplete(); + }); + + // wire: 박스 필드만 setBounds op으로 실린다 + expect(mocks.commitMixedGesture).toHaveBeenCalledWith( + pluginGestureIds[0], + { + opsVersion: EDITOR_OPS_VERSION, + ops: [ + { + kind: 'setBounds', + elementType: 'sprite', + id: STABLE_SPRITE, + bounds: { dx: 10, dy: 20, width: 100, height: 60 }, + }, + ], + }, + ['plugin-a'], + expect.anything(), + ); + // eager: 활동 영역만 바뀌고 요소 로컬 데이터는 참조까지 그대로다 + const eager = useSpriteStore.getState().positions['4key'][0]; + expect(eager).toMatchObject({ dx: 10, dy: 20, width: 100, height: 60 }); + expect(eager.imageRect).toBe(original.imageRect); + expect(eager.pivot).toBe(original.pivot); + expect(eager.poses).toBe(original.poses); + expect(eager.idleTransform).toBe(original.idleTransform); + }); + it('혼합 그룹의 native ID가 비정규면 plugin까지 함께 fail-close한다', async () => { mocks.elements = [{ fullId: 'plugin-a:one', pluginId: 'plugin-a' }]; const selected = [keySelection(), pluginSelection('plugin-a:one')]; diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index 8839bd7af..13563f23d 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -843,7 +843,7 @@ export function useGridResize({ } if (finalBounds && frozenTargets.length === 1) { const element = frozenTargets[0] as { - type: 'key' | 'stat' | 'graph' | 'knob' | 'plugin'; + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite' | 'plugin'; id: string; index?: number; }; @@ -930,14 +930,19 @@ export function useGridResize({ // 구성. native-only는 전용 의도 커밋이 eager와 wire를 함께 소유하고, // plugin 혼합은 eager receipt와 슬롯 generator가 각 경계를 소유한다 const stableBoundsIntents = new Map< - 'key' | 'stat' | 'graph' | 'knob', + 'key' | 'stat' | 'graph' | 'knob' | 'sprite', Map> >(); const isStableEntry = (element: { type: string; id: string }): boolean => element.type !== 'plugin' && isNativeElementId(element.id); for (const { element, bounds } of finalData.elementBounds) { if (!isStableEntry(element)) continue; - const type = element.type as 'key' | 'stat' | 'graph' | 'knob'; + const type = element.type as + | 'key' + | 'stat' + | 'graph' + | 'knob' + | 'sprite'; const byId = stableBoundsIntents.get(type) ?? new Map(); byId.set(element.id, { dx: bounds.x, diff --git a/src/renderer/hooks/Grid/useSmartGuidesElements.ts b/src/renderer/hooks/Grid/useSmartGuidesElements.ts index 2ebde30d7..da927f708 100644 --- a/src/renderer/hooks/Grid/useSmartGuidesElements.ts +++ b/src/renderer/hooks/Grid/useSmartGuidesElements.ts @@ -6,6 +6,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { calculateBounds, type ElementBounds } from '@utils/grid/smartGuides'; @@ -23,6 +24,7 @@ const getOtherElementsSnapshot = ( const statPositions = useStatItemStore.getState().positions; const graphPositions = useGraphItemStore.getState().positions; const knobPositions = useKnobItemStore.getState().positions; + const spritePositions = useSpriteStore.getState().positions; const pluginElements = usePluginDisplayElementStore.getState().elements; const bounds: ElementBounds[] = []; @@ -85,6 +87,18 @@ const getOtherElementsSnapshot = ( } }); + // 스프라이트 요소 bounds + const sprites = spritePositions[selectedKeyType] || []; + sprites.forEach((pos) => { + if (!pos || pos.hidden) return; + const id = pos.id; + if (!excludeSet.has(id)) { + bounds.push( + calculateBounds(pos.dx, pos.dy, pos.width || 60, pos.height || 60, id), + ); + } + }); + // 플러그인 요소 bounds (현재 탭에 속하는 요소만) pluginElements.forEach((el) => { if (el.hidden) return; diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index b6af51958..6f64d7934 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -50,7 +50,7 @@ import { appApi } from '@api/modules/appApi'; import { useUIStore } from '@stores/useUIStore'; -type ToolbarAddItemType = 'key' | 'stat' | 'graph' | 'knob'; +type ToolbarAddItemType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message) { From 4b8867dd984b4f7eb68efee97ad8db314c78d9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 30 Aug 2026 22:04:09 +0900 Subject: [PATCH 006/125] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=86=8D=EC=84=B1=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EA=B3=BC=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=ED=83=AD=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/api/modules/itemsApi.ts | 21 + .../components/main/Grid/PropertiesPanel.tsx | 91 +- .../batch/useBatchHandlers.test.ts | 45 + .../PropertiesPanel/batch/useBatchHandlers.ts | 9 +- .../main/Grid/PropertiesPanel/index.ts | 1 + .../Grid/PropertiesPanel/layer/LayerIcons.tsx | 28 + .../PropertiesPanel/layer/LayerTabContent.tsx | 7 + .../layer/layerPanelModel.test.ts | 2 + .../PropertiesPanel/layer/layerPanelModel.ts | 16 + .../layer/layerReorderIntent.test.ts | 1 + .../layer/layerReorderIntent.ts | 5 +- .../PropertiesPanel/layer/useLayerActions.ts | 13 +- .../PropertiesPanel/previewPatchForwarders.ts | 6 +- .../selectionPanelRoute.test.ts | 62 ++ .../PropertiesPanel/selectionPanelRoute.ts | 23 +- .../single/SingleSpritePanel.test.tsx | 265 ++++++ .../single/SingleSpritePanel.tsx | 875 ++++++++++++++++++ .../main/Grid/PropertiesPanel/types.ts | 2 +- src/renderer/locales/en.json | 27 +- src/renderer/locales/ko.json | 27 +- src/renderer/locales/ru.json | 27 +- src/renderer/locales/zh-Hant.json | 27 +- src/renderer/locales/zh-cn.json | 27 +- 23 files changed, 1582 insertions(+), 25 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx diff --git a/src/renderer/api/modules/itemsApi.ts b/src/renderer/api/modules/itemsApi.ts index 498e098aa..6a0931dbb 100644 --- a/src/renderer/api/modules/itemsApi.ts +++ b/src/renderer/api/modules/itemsApi.ts @@ -6,6 +6,7 @@ import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoord import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; +import type { SpritePositions } from '@src/types/key/sprites'; import type { LayerGroups } from '@src/types/layerGroups'; export const statItemsApi = { @@ -53,6 +54,26 @@ export const knobItemsApi = { subscribe('knobPositions:changed', listener), }; +export const spriteItemsApi = { + getPositions: () => invoke('sprite_positions_get'), + // 스프라이트 전용 필드는 ops patchElement가 거부하므로 전체 필드 패치로 커밋. + // gestureId는 숫자 스크럽 preview 게스처 정산용 + updatePositions: (positions: SpritePositions, gestureId?: string) => + enqueueEditorCompatibilityWrite( + () => + editorCoordinator.commitPatch( + { + schemaVersion: 1, + spritePositions: positions, + }, + gestureId ? { gestureId } : undefined, + ), + () => structuredClone(positions), + ), + onPositionsChanged: (listener: (positions: SpritePositions) => void) => + subscribe('spritePositions:changed', listener), +}; + export const layerGroupsApi = { get: () => invoke('layer_groups_get'), update: (groups: LayerGroups) => diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 816b43d5c..1668bfeae 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -13,6 +13,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useSettingsStore } from '@stores/useSettingsStore'; import { selectPropertyPanelPluginElements, @@ -54,6 +55,7 @@ import type { EditorElementPropertyPatchV1, CanonicalKeyPosition, CanonicalKnobItemPosition, + CanonicalReactiveSpritePosition, } from '@src/types/editor'; import type { PluginSettingSchema, @@ -129,6 +131,7 @@ import { PluginSelectionPanel, SingleGraphPanel, SingleKnobPanel, + SingleSpritePanel, SingleKeyStatPanel, BatchKeyLikePanel, BatchGraphOnlyPanel, @@ -407,6 +410,7 @@ const PropertiesPanel: React.FC = ({ const canonicalKnobItemPositions = useKnobItemStore( (state) => state.positions, ); + const canonicalSpritePositions = useSpriteStore((state) => state.positions); useSyncExternalStore( subscribePreviewOverlay, getPreviewOverlayVersion, @@ -424,6 +428,10 @@ const PropertiesPanel: React.FC = ({ 'knobPosition', canonicalKnobItemPositions, ); + const spriteItemPositions = composePreviewPositions( + 'spritePosition', + canonicalSpritePositions, + ); const { useCustomCSS } = useSettingsStore(); const pluginElements = usePluginDisplayElementStore( selectPropertyPanelPluginElements, @@ -464,6 +472,9 @@ const PropertiesPanel: React.FC = ({ const selectedKnobElements = selectedElements.filter( (el) => el.type === 'knob', ); + const selectedSpriteElements = selectedElements.filter( + (el) => el.type === 'sprite', + ); const selectedKeyLikeElements = selectedElements.filter( (el) => el.type === 'key' || el.type === 'stat', ); @@ -474,6 +485,16 @@ const PropertiesPanel: React.FC = ({ el.type === 'graph' || el.type === 'knob', ); + // 기하 대상은 스타일 대상과 별개 - sprite는 배치 스타일이 없지만 + // 정렬·분배·간격·이동에는 참여한다 + const selectedBatchGeometryElements = selectedElements.filter( + (el) => + el.type === 'key' || + el.type === 'stat' || + el.type === 'graph' || + el.type === 'knob' || + el.type === 'sprite', + ); const selectedPluginElements = selectedElements.filter( (el) => el.type === 'plugin', ); @@ -488,10 +509,10 @@ const PropertiesPanel: React.FC = ({ // plugin 단독 배치는 native 대상 0개(빈 배열)로 성립 - invalid ID가 // 하나라도 섞이면 null (fail-closed) const stableBatchGeometryTargets: BatchGeometryTarget[] | null = - selectedBatchStyleElements.every( + selectedBatchGeometryElements.every( (element) => element.id.length > 0 && isNativeElementId(element.id), ) - ? selectedBatchStyleElements.map((element) => ({ + ? selectedBatchGeometryElements.map((element) => ({ type: element.type as EditorElementTypeV1, id: element.id, })) @@ -615,6 +636,14 @@ const PropertiesPanel: React.FC = ({ singleKnobIndex >= 0 ? knobItemPositions[selectedKeyType]?.[singleKnobIndex] ?? null : null; + const singleSpriteId = + selectedSpriteElements.length === 1 ? selectedSpriteElements[0].id : null; + const singleSpritePosition: CanonicalReactiveSpritePosition | null = + singleSpriteId + ? (spriteItemPositions[selectedKeyType] ?? []).find( + (position) => position.id === singleSpriteId, + ) ?? null + : null; const allLayerGroups = useLayerGroupStore((state) => state.layerGroups); const layerGroupsForMode = allLayerGroups[selectedKeyType] || []; const selectedGroupInfo = (() => { @@ -626,6 +655,7 @@ const PropertiesPanel: React.FC = ({ const statModePositions = statItemPositions[selectedKeyType] || []; const graphModePositions = graphItemPositions[selectedKeyType] || []; const knobModePositions = knobItemPositions[selectedKeyType] || []; + const spriteModePositions = spriteItemPositions[selectedKeyType] || []; // 플러그인 소속은 현재 모드에 def가 있는 groupId만 유효 (레이어 패널과 // 동일 규칙) const modeGroupIds = new Set(layerGroupsForMode.map((group) => group.id)); @@ -650,6 +680,10 @@ const PropertiesPanel: React.FC = ({ currentGroupId = knobModePositions.find( (position) => position.id === element.id, )?.groupId; + } else if (element.type === 'sprite') { + currentGroupId = + spriteModePositions.find((position) => position.id === element.id) + ?.groupId ?? undefined; } else if (element.type === 'plugin') { const pluginGroupId = pluginElements.find( (candidate) => candidate.fullId === element.id, @@ -677,6 +711,7 @@ const PropertiesPanel: React.FC = ({ statModePositions.filter((pos) => pos?.groupId === groupId).length + graphModePositions.filter((pos) => pos?.groupId === groupId).length + knobModePositions.filter((pos) => pos?.groupId === groupId).length + + spriteModePositions.filter((pos) => pos?.groupId === groupId).length + pluginElements.filter( (el) => isPluginVisibleInMode(el, selectedKeyType) && el.groupId === groupId, @@ -823,6 +858,7 @@ const PropertiesPanel: React.FC = ({ selectedPluginElements.length, selectedGraphElements.length, selectedKnobElements.length, + selectedSpriteElements.length, ].join('|'); useEffect(() => { closePage(); @@ -912,6 +948,7 @@ const PropertiesPanel: React.FC = ({ if (singleStatPosition) return singleStatPosition.layerName || ''; if (singleGraphPosition) return singleGraphPosition.layerName || ''; if (singleKnobPosition) return singleKnobPosition.layerName || ''; + if (singleSpritePosition) return singleSpritePosition.layerName || ''; return ''; }; @@ -928,6 +965,7 @@ const PropertiesPanel: React.FC = ({ return `${getStatTypeLabel(singleGraphPosition.statType ?? null)} Graph`; } if (singleKnobPosition) return 'Knob'; + if (singleSpritePosition) return 'Sprite'; return ''; }; @@ -1017,7 +1055,8 @@ const PropertiesPanel: React.FC = ({ singleKeyPosition || singleStatPosition || singleGraphPosition || - singleKnobPosition + singleKnobPosition || + singleSpritePosition ) { setPanelMode('property'); handleRenameStart(); @@ -1030,6 +1069,7 @@ const PropertiesPanel: React.FC = ({ singleStatPosition, singleGraphPosition, singleKnobPosition, + singleSpritePosition, setPanelMode, ]); @@ -1185,7 +1225,8 @@ const PropertiesPanel: React.FC = ({ // 다중 선택 시 패널 자동 열기 - 개수는 native+plugin 합산 useEffect(() => { if ( - selectedBatchStyleElements.length + selectedPluginElements.length > 1 && + selectedBatchGeometryElements.length + selectedPluginElements.length > + 1 && !isPanelVisible && !manuallyClosedRef.current ) { @@ -1193,7 +1234,7 @@ const PropertiesPanel: React.FC = ({ setIsPanelVisible(true); } }, [ - selectedBatchStyleElements.length, + selectedBatchGeometryElements.length, selectedPluginElements.length, isPanelVisible, setIsPanelVisible, @@ -2077,8 +2118,8 @@ const PropertiesPanel: React.FC = ({ handleBatchResize, handleBatchResizePreview, } = useBatchHandlers({ - selectedKeyLikeElements: selectedBatchStyleElements as { - type: 'key' | 'stat' | 'graph' | 'knob'; + selectedKeyLikeElements: selectedBatchGeometryElements as { + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; id: string; index?: number; }[], @@ -2087,6 +2128,7 @@ const PropertiesPanel: React.FC = ({ graphPositions: graphItemPositions, selectedKeyType, knobPositions: knobItemPositions, + spritePositions: spriteItemPositions, pluginLayoutElements: stablePluginGeometryElements, onStableGeometryPreview: (operation) => { if (!stableBatchGeometryTargets) return; @@ -2095,7 +2137,7 @@ const PropertiesPanel: React.FC = ({ string, { type: EditorElementTypeV1; - position: KeyPosition; + position: Pick; } >(); for (const target of stableBatchGeometryTargets) { @@ -2108,7 +2150,9 @@ const PropertiesPanel: React.FC = ({ ? statItemPositions : target.type === 'graph' ? graphItemPositions - : knobItemPositions; + : target.type === 'knob' + ? knobItemPositions + : spriteItemPositions; const position = record[selectedKeyType]?.[locator.index]; if (!position || position.id !== target.id) return; targetsByKey.set(`${target.type}:${target.id}`, { @@ -2165,7 +2209,9 @@ const PropertiesPanel: React.FC = ({ ? 'statPosition' : type === 'graph' ? 'graphPosition' - : 'knobPosition', + : type === 'knob' + ? 'knobPosition' + : 'spritePosition', }); } }, @@ -2710,16 +2756,18 @@ const PropertiesPanel: React.FC = ({ // 플러그인 단독 다중은 경량 기하 배치 패널에 라우트 const renderSelectionPanelBody = () => { const selectionTotalCount = - selectedBatchStyleElements.length + selectedPluginElements.length; + selectedBatchGeometryElements.length + selectedPluginElements.length; const route = resolveSelectionPanelRoute({ keyLikeCount: selectedKeyLikeElements.length, graphCount: selectedGraphElements.length, knobCount: selectedKnobElements.length, + spriteCount: selectedSpriteElements.length, pluginCount: selectedPluginElements.length, hasSingleKeyPosition: !!singleKeyPosition, hasSingleStatPosition: !!singleStatPosition, hasSingleGraphPosition: !!singleGraphPosition, hasSingleKnobPosition: !!singleKnobPosition, + hasSingleSpritePosition: !!singleSpritePosition, }); // 다중 선택인 경우 (키/통계 포함, 또는 그래프+노브 혼합) @@ -2940,6 +2988,27 @@ const PropertiesPanel: React.FC = ({ ); } + // 단일 스프라이트 요소 선택인 경우 - 전용 필드 커밋·프리뷰는 패널이 소유 + if (route.kind === 'singleSprite' && singleSpritePosition) { + return ( + + ); + } + // 단일 노브 요소 선택인 경우 if (route.kind === 'singleKnob' && singleKnobPosition) { return ( diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts index 00d3e5782..c3a3e330b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.test.ts @@ -89,6 +89,51 @@ describe('useBatchHandlers stable geometry', () => { }); }); + it('sprite 대상도 기하 조작에 참여한다', () => { + vi.mocked(resolveElementById).mockImplementation((type, id) => + type === 'key' && id === IDS.first + ? { type: 'key', mode: '4key', index: 0 } + : type === 'sprite' && id === IDS.second + ? { type: 'sprite', mode: '4key', index: 0 } + : null, + ); + const onStableGeometryCommit = vi.fn(); + const onStableGeometryPreview = vi.fn(); + const handlers = useBatchHandlers({ + selectedKeyLikeElements: [ + { type: 'key', id: IDS.first }, + { type: 'sprite', id: IDS.second }, + ], + keyPositions: { '4key': [position(IDS.first, 0)] }, + statPositions: {}, + spritePositions: { + '4key': [ + { id: IDS.second, dx: 40, dy: 0, width: 20, height: 10 } as never, + ], + }, + selectedKeyType: '4key', + onStableGeometryCommit, + onStableGeometryPreview, + }); + + handlers.handleBatchAlign('left'); + handlers.handleBatchSpacingPreview(5); + + expect(onStableGeometryCommit).toHaveBeenCalledWith({ + kind: 'align', + direction: 'left', + }); + expect(onStableGeometryPreview).toHaveBeenCalledWith({ + kind: 'spacing', + spacing: 5, + }); + // sprite 위치가 간격 계산에 실제로 잡힌다 + expect(handlers.getBatchSpacingValue()).toEqual({ + isMixed: false, + value: 30, + }); + }); + it('대상 ID를 현재 문서에서 모두 찾지 못하면 preview와 commit을 막는다', () => { vi.mocked(resolveElementById).mockImplementation((_type, id) => id === IDS.first ? { type: 'key', mode: '4key', index: 1 } : null, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts index 44860827f..166b4b7a1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/useBatchHandlers.ts @@ -9,8 +9,9 @@ import type { KeyPosition } from '@src/types/key/keys'; import type { StatItemPosition } from '@src/types/key/statItems'; import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; -type KeyLikeType = 'key' | 'stat' | 'graph' | 'knob'; +type KeyLikeType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; export interface BatchCommitOptions { gestureId?: string; @@ -35,6 +36,7 @@ interface UseBatchHandlersProps { statPositions: Record; graphPositions?: Record; knobPositions?: Record; + spritePositions?: Record; selectedKeyType: string; // 혼합 선택의 plugin 대상 bounds - null은 대상 미해결이라 기하 조작 전체 차단 pluginLayoutElements?: PluginLayoutElement[] | null; @@ -57,6 +59,7 @@ export function useBatchHandlers({ statPositions, graphPositions, knobPositions, + spritePositions, selectedKeyType, pluginLayoutElements = [], onStableGeometryCommit, @@ -72,7 +75,9 @@ export function useBatchHandlers({ ? statPositions : type === 'graph' ? graphPositions - : knobPositions; + : type === 'knob' + ? knobPositions + : spritePositions; const position = record?.[selectedKeyType]?.[locator.index]; return position?.id === id ? position : null; }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/index.ts b/src/renderer/components/main/Grid/PropertiesPanel/index.ts index 3f776eff6..f7b950592 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/index.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/index.ts @@ -36,6 +36,7 @@ export { SingleKnobPanel, SingleKeyStatPanel, } from './single/SingleSelectionPanel'; +export { SingleSpritePanel } from './single/SingleSpritePanel'; export { BatchKeyLikePanel, BatchGraphOnlyPanel, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerIcons.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerIcons.tsx index 191d71393..d27357467 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerIcons.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerIcons.tsx @@ -127,3 +127,31 @@ export const KnobIcon: React.FC = () => ( /> ); + +// 스프라이트: 이미지 프레임 + 다음 프레임 잔상 (움직이는 그림) +export const SpriteIcon: React.FC = () => ( + + + + + +); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index b1fcfe7c0..a507432e9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -11,6 +11,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { selectPropertyPanelPluginElements, usePluginDisplayElementStore, @@ -33,6 +34,7 @@ import { PluginIcon, StatIcon, GraphIcon, + SpriteIcon, } from './LayerIcons'; import { useLayerActions } from './useLayerActions'; import { useLayerDnD } from './useLayerDnD'; @@ -153,6 +155,7 @@ const LayerTabContent: React.FC = ({ const statPositions = useStatItemStore((state) => state.positions); const graphPositions = useGraphItemStore((state) => state.positions); const knobPositions = useKnobItemStore((state) => state.positions); + const spritePositions = useSpriteStore((state) => state.positions); const pluginElements = usePluginDisplayElementStore( selectPropertyPanelPluginElements, ); @@ -219,6 +222,7 @@ const LayerTabContent: React.FC = ({ statPositions, graphPositions, knobPositions, + spritePositions, pluginElements, layerGroupsForMode, }); @@ -294,6 +298,7 @@ const LayerTabContent: React.FC = ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, // 패널 창의 elements는 항상 비어 있으므로 창별 미러 셀렉터를 경유한다 pluginElements: selectPropertyPanelPluginElements( usePluginDisplayElementStore.getState(), @@ -804,6 +809,8 @@ const LayerTabContent: React.FC = ({ ) : item.type === 'knob' ? ( + ) : item.type === 'sprite' ? ( + ) : ( )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts index 34836ce36..42c263f8c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.test.ts @@ -24,6 +24,7 @@ describe('layer panel plugin mode scope', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, pluginElements: [ plugin('same', '4key'), plugin('global'), @@ -43,6 +44,7 @@ describe('layer panel plugin mode scope', () => { statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, pluginElements: [ plugin('grouped', '4key', 'group-a'), plugin('dangling', '4key', 'group-missing'), diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts index 0df5eed7f..80b1638b1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerPanelModel.ts @@ -22,6 +22,7 @@ interface BuildLayerItemsParams { statPositions: CanonicalEditorDocumentV1['statPositions']; graphPositions: CanonicalEditorDocumentV1['graphPositions']; knobPositions: CanonicalEditorDocumentV1['knobPositions']; + spritePositions: CanonicalEditorDocumentV1['spritePositions']; pluginElements: PluginPanelElementView[]; layerGroupsForMode: LayerGroupDef[]; } @@ -33,6 +34,7 @@ export function buildLayerItems({ statPositions, graphPositions, knobPositions, + spritePositions, pluginElements, layerGroupsForMode, }: BuildLayerItemsParams): LayerItem[] { @@ -113,6 +115,20 @@ export function buildLayerItems({ }); }); + // 스프라이트 아이템 + const currentSpritePositions = spritePositions[selectedKeyType] || []; + currentSpritePositions.forEach((pos, index) => { + items.push({ + type: 'sprite', + id: pos.id, + index, + name: pos.layerName || `Sprite ${index + 1}`, + zIndex: pos.zIndex ?? index, + hidden: !!pos.hidden, + groupId: pos.groupId ?? undefined, + }); + }); + // 플러그인 아이템 - 그룹은 모드 스코프라 현재 모드에 def가 있는 groupId만 // 노출 (dangling group_id의 유령 헤더 방지) const validGroupIds = new Set(layerGroupsForMode.map((group) => group.id)); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts index 33e5b352f..840fa96d3 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.test.ts @@ -48,6 +48,7 @@ const documentWith = ( statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: groups.length > 0 ? { '4key': groups } : {}, } as never); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts index b5be7643e..93d2e9347 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/layerReorderIntent.ts @@ -1,6 +1,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; @@ -33,7 +34,7 @@ import type { import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import type { DisplayItem, LayerItem } from '../types'; -type NativeType = 'key' | 'stat' | 'graph' | 'knob'; +type NativeType = 'key' | 'stat' | 'graph' | 'knob' | 'sprite'; export interface DropAnchors { toDisplayIndex: number; @@ -274,6 +275,7 @@ const modelFrom = ( document.statPositions, document.graphPositions, document.knobPositions, + document.spritePositions, [...pluginElements], ).map( (item): LayerItem => ({ @@ -602,6 +604,7 @@ const localDocument = (): CanonicalEditorDocumentV1 => ({ statPositions: useStatItemStore.getState().positions, graphPositions: useGraphItemStore.getState().positions, knobPositions: useKnobItemStore.getState().positions, + spritePositions: useSpriteStore.getState().positions, layerGroups: useLayerGroupStore.getState().layerGroups, }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts index 8512bd564..51d8b63ab 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts @@ -9,6 +9,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { buildNextLayerGroupName } from '@utils/layerGroupUtils'; @@ -67,7 +68,8 @@ const stableSelectionGroupId = ( element.type === 'key' || element.type === 'stat' || element.type === 'graph' || - element.type === 'knob', + element.type === 'knob' || + element.type === 'sprite', ); const plugin = elements.filter((element) => element.type === 'plugin'); if ( @@ -85,6 +87,12 @@ const stableSelectionGroupId = ( for (const element of native) { const locator = resolveElementById(element.type, element.id); if (!locator || locator.mode !== mode) return { stable: true }; + if (element.type === 'sprite') { + const groupId = + useSpriteStore.getState().positions[mode]?.[locator.index]?.groupId; + if (groupId) groupIds.add(groupId); + continue; + } const record = element.type === 'key' ? useKeyStore.getState().canonicalPositions @@ -289,7 +297,8 @@ export function useLayerActions({ element.type === 'key' || element.type === 'stat' || element.type === 'graph' || - element.type === 'knob' + element.type === 'knob' || + element.type === 'sprite' ? [{ elementType: element.type, id: element.id }] : [], ); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/previewPatchForwarders.ts b/src/renderer/components/main/Grid/PropertiesPanel/previewPatchForwarders.ts index b8572de03..38bb56683 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/previewPatchForwarders.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/previewPatchForwarders.ts @@ -120,6 +120,8 @@ export const previewSingleStyleProperty = ( id: string, patch: EditorStylePropertyPreviewPatchV1, ): void => { + // 스프라이트는 스타일 프리뷰 대상이 아니다 + if (type === 'sprite') return; const locator = resolveElementById(type, id); if (!locator) return; if (isShadowPreviewPatch(patch)) { @@ -261,10 +263,12 @@ export const previewBatchPaint = ( }; export const previewSinglePaint = ( - type: PreviewTargetType, + type: EditorElementTypeV1, id: string, patch: EditorPaintPropertyPatchV1, ): void => { + // 스프라이트는 페인트 프리뷰 대상이 아니다 + if (type === 'sprite') return; const locator = resolveElementById(type, id); if (!locator) return; previewBatchPaint([{ elementType: type, id }], locator.mode, patch); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.test.ts index b7e69bf81..cbc2e418f 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.test.ts @@ -11,11 +11,13 @@ const input = ( keyLikeCount: 0, graphCount: 0, knobCount: 0, + spriteCount: 0, pluginCount: 0, hasSingleKeyPosition: false, hasSingleStatPosition: false, hasSingleGraphPosition: false, hasSingleKnobPosition: false, + hasSingleSpritePosition: false, ...overrides, }); @@ -159,3 +161,63 @@ describe('selectionPanelRoute 혼합 선택(네이티브+플러그인)', () => { ).toEqual({ kind: 'batchKnobOnly' }); }); }); + +describe('selectionPanelRoute 스프라이트 선택', () => { + it('단일 스프라이트는 singleSprite', () => { + expect( + resolveSelectionPanelRoute( + input({ spriteCount: 1, hasSingleSpritePosition: true }), + ), + ).toEqual({ kind: 'singleSprite' }); + }); + + it('위치 미해결 단일 스프라이트는 none', () => { + expect(resolveSelectionPanelRoute(input({ spriteCount: 1 }))).toEqual({ + kind: 'none', + }); + }); + + it('스프라이트만 다중이면 경량 기하 배치', () => { + expect(resolveSelectionPanelRoute(input({ spriteCount: 2 }))).toEqual({ + kind: 'pluginBatch', + }); + }); + + it('스프라이트+플러그인은 경량 기하 배치', () => { + expect( + resolveSelectionPanelRoute( + input({ + spriteCount: 1, + hasSingleSpritePosition: true, + pluginCount: 1, + }), + ), + ).toEqual({ kind: 'pluginBatch' }); + }); + + it('스프라이트+키는 batchKeyLike', () => { + expect( + resolveSelectionPanelRoute(input({ spriteCount: 1, keyLikeCount: 1 })), + ).toEqual({ kind: 'batchKeyLike' }); + }); + + it('스프라이트+노브는 batchKnobOnly', () => { + expect( + resolveSelectionPanelRoute(input({ spriteCount: 1, knobCount: 1 })), + ).toEqual({ kind: 'batchKnobOnly' }); + }); + + it('스프라이트+그래프는 batchGraphOnly', () => { + expect( + resolveSelectionPanelRoute(input({ spriteCount: 1, graphCount: 1 })), + ).toEqual({ kind: 'batchGraphOnly' }); + }); + + it('스프라이트가 섞여도 단일 노브 라우트로 새지 않는다', () => { + expect( + resolveSelectionPanelRoute( + input({ spriteCount: 1, knobCount: 1, hasSingleKnobPosition: true }), + ), + ).toEqual({ kind: 'batchKnobOnly' }); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.ts b/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.ts index 04afd4d59..8b5fd983c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/selectionPanelRoute.ts @@ -11,17 +11,20 @@ export type SelectionPanelRouteKind = | 'singleKnob' | 'singleGraph' | 'singleKeyStat' + | 'singleSprite' | 'none'; export interface SelectionPanelRouteInput { keyLikeCount: number; graphCount: number; knobCount: number; + spriteCount: number; pluginCount: number; hasSingleKeyPosition: boolean; hasSingleStatPosition: boolean; hasSingleGraphPosition: boolean; hasSingleKnobPosition: boolean; + hasSingleSpritePosition: boolean; } export interface SelectionPanelRoute { @@ -35,13 +38,15 @@ export const resolveSelectionPanelRoute = ( keyLikeCount, graphCount, knobCount, + spriteCount, pluginCount, hasSingleKeyPosition, hasSingleStatPosition, hasSingleGraphPosition, hasSingleKnobPosition, + hasSingleSpritePosition, } = input; - const nativeCount = keyLikeCount + graphCount + knobCount; + const nativeCount = keyLikeCount + graphCount + knobCount + spriteCount; // native 없음 - 플러그인 단독 경로 (단일 편집 또는 경량 기하 배치) if (nativeCount === 0) { @@ -57,7 +62,21 @@ export const resolveSelectionPanelRoute = ( return { kind: 'batchKeyLike' }; } if (knobCount > 0) return { kind: 'batchKnobOnly' }; - return { kind: 'batchGraphOnly' }; + if (graphCount > 0) return { kind: 'batchGraphOnly' }; + // 스프라이트 단독 다중(또는 스프라이트+플러그인)은 전용 스타일 배치가 없어 + // 정렬·분배·간격만 있는 경량 기하 배치로 + return { kind: 'pluginBatch' }; + } + + // 단일 스프라이트 + if ( + spriteCount === 1 && + hasSingleSpritePosition && + keyLikeCount === 0 && + graphCount === 0 && + knobCount === 0 + ) { + return { kind: 'singleSprite' }; } // 단일 노브 diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx new file mode 100644 index 000000000..e1d36434f --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -0,0 +1,265 @@ +import React, { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SingleSpritePanel } from './SingleSpritePanel'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + updatePositions: vi.fn((_positions: unknown, _gestureId?: string) => + Promise.resolve({}), + ), +})); + +vi.mock('@api/modules/itemsApi', () => ({ + spriteItemsApi: { updatePositions: mocks.updatePositions }, +})); + +vi.mock('@api/modules/resourceApi', () => ({ + imageApi: { load: vi.fn(() => Promise.resolve({ success: false })) }, +})); + +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ scrollContainerRef: vi.fn() }), +})); + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const KEY_ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const KEY_ID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const OTHER_SPRITE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + +const spritePosition = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => ({ + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + ...overrides, +}); + +describe('SingleSpritePanel 자세 편집', () => { + let container: HTMLDivElement; + let root: Root; + + const render = (position: CanonicalReactiveSpritePosition) => { + act(() => { + root.render( + () as never} + renameValue="" + setRenameValue={vi.fn()} + renameCancelledRef={{ current: false }} + handleRenameCommit={vi.fn()} + handleRenameCancel={vi.fn()} + handleRenameStart={vi.fn()} + singleScrollRefFor={() => vi.fn()} + t={((key: string) => key) as never} + />, + ); + }); + }; + + const seed = (position: CanonicalReactiveSpritePosition) => { + useSpriteStore.setState({ + positions: { + '4key': [position, spritePosition({ id: OTHER_SPRITE_ID })], + }, + }); + }; + + const buttonByText = (text: string) => + [...container.querySelectorAll('button')].find( + (button) => button.textContent === text, + )!; + + const chipByTitle = (title: string) => + container.querySelector(`button[title="${title}"]`)!; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.updatePositions.mockResolvedValue({}); + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A', 'S'] }, + canonicalPositions: { + '4key': [{ id: KEY_ID_A }, { id: KEY_ID_B }], + } as never, + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useSpriteStore.setState({ positions: {} }); + }); + + it('자세 추가는 담당 키가 비어 커밋하지 않고 행만 표시한다', () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + + expect(mocks.updatePositions).not.toHaveBeenCalled(); + expect(container.textContent).toContain('propertiesPanel.spritePose 1'); + expect(container.textContent).toContain( + 'propertiesPanel.spriteEmptyTriggers', + ); + }); + + it('담당 키를 선택하면 전체 필드 패치로 커밋한다', () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + act(() => chipByTitle('A').click()); + + expect(mocks.updatePositions).toHaveBeenCalledTimes(1); + const [rawRecord, gestureId] = mocks.updatePositions.mock.calls[0]; + const record = rawRecord as Record< + string, + CanonicalReactiveSpritePosition[] + >; + expect(gestureId).toBeUndefined(); + expect(record['4key']).toHaveLength(2); + expect(record['4key'][0].poses).toEqual([ + { + poseId: expect.any(String), + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ]); + // 같은 모드의 다른 스프라이트는 그대로 유지 + expect(record['4key'][1]).toEqual( + useSpriteStore.getState().positions['4key'][1], + ); + }); + + it('중복 트리거 집합은 커밋을 막고 경고를 표시한다', () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + // 두 번째 자세의 A 칩 (첫 자세 칩 다음) + const chips = [ + ...container.querySelectorAll('button[title="A"]'), + ]; + expect(chips).toHaveLength(2); + act(() => chips[1].click()); + + expect(mocks.updatePositions).not.toHaveBeenCalled(); + expect(container.textContent).toContain( + 'propertiesPanel.spriteDuplicateTriggers', + ); + }); + + it('자세 삭제는 남은 자세만으로 커밋한다', () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + { + poseId: 'pose-2', + triggers: [KEY_ID_B], + matchMode: 'exact', + transform: { x: 4, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + const removeButtons = [ + ...container.querySelectorAll( + 'button[title="propertiesPanel.spriteRemovePose"]', + ), + ]; + expect(removeButtons).toHaveLength(2); + act(() => removeButtons[0].click()); + + expect(mocks.updatePositions).toHaveBeenCalledTimes(1); + const record = mocks.updatePositions.mock.calls[0][0] as Record< + string, + CanonicalReactiveSpritePosition[] + >; + expect(record['4key'][0].poses.map((pose) => pose.poseId)).toEqual([ + 'pose-2', + ]); + }); + + it('죽은 트리거 참조는 표시되고 클릭으로 제거된다', () => { + const deadId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A, deadId], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + const deadChip = buttonByText('propertiesPanel.spriteMissingKey'); + expect(deadChip).toBeTruthy(); + act(() => deadChip.click()); + + expect(mocks.updatePositions).toHaveBeenCalledTimes(1); + const record = mocks.updatePositions.mock.calls[0][0] as Record< + string, + CanonicalReactiveSpritePosition[] + >; + expect(record['4key'][0].poses[0].triggers).toEqual([KEY_ID_A]); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx new file mode 100644 index 000000000..aafc7842d --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -0,0 +1,875 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; +import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; +import { isNativeElementId } from '@src/renderer/editor/model/elementId'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { useKeyStore } from '@stores/data/useKeyStore'; +import { spriteItemsApi } from '@api/modules/itemsApi'; +import { imageApi } from '@api/modules/resourceApi'; +import { canDecodeImage } from '@utils/core/assetProbe'; +import { slotDisplayName } from '@utils/keySlot'; +import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; +import { + DEFAULT_SPRITE_TRANSITION_EASING, + SPRITE_CONSTRAINTS, + findDuplicateTriggerPose, + type ReactiveSpritePosition, + type SpriteImageFit, + type SpritePose, + type SpriteTransform, +} from '@src/types/key/sprites'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; +import { + PropertyRow, + PropertySection, + NumberInput, + CloseIcon, + TABS, + TabType, +} from '../index'; +import Dropdown from '@components/main/common/Dropdown'; +import EditSessionBoundary from '../EditSessionBoundary'; + +// 공용 버튼 크롬 (knob 패널의 설정 버튼과 동일) +const ACTION_BUTTON_CLASS = + 'px-[8px] h-[23px] bg-fill hover:bg-fill-hover active:bg-fill-active transition-colors duration-fast rounded-md flex items-center justify-center text-fg text-body'; + +// 24 그리드를 12px로 렌더 - 스트로크 2.4가 화면상 1.2 +const RenameIcon: React.FC = () => ( + +); + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +// 계약과 동일한 cubic-bezier 문자열만 저장 (transitionEasing은 문자열 그대로 CSS로 간다) +const SPRITE_EASING_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ + { label: 'Default', value: DEFAULT_SPRITE_TRANSITION_EASING }, + { label: 'Linear', value: 'cubic-bezier(0, 0, 1, 1)' }, + { label: 'Ease Out', value: 'cubic-bezier(0, 0, 0.58, 1)' }, + { label: 'Ease In', value: 'cubic-bezier(0.42, 0, 1, 1)' }, + { label: 'Ease In-Out', value: 'cubic-bezier(0.42, 0, 0.58, 1)' }, + { label: 'Overshoot', value: 'cubic-bezier(0.34, 1.56, 0.64, 1)' }, +]; + +interface SpriteTransformFieldsProps { + transform: SpriteTransform; + onCommit: (next: SpriteTransform) => void; + onPreview?: (next: SpriteTransform) => void; + t: (key: string) => string; +} + +// 대기 자세와 각 자세 행이 공유하는 변환 4필드 +const SpriteTransformFields: React.FC = ({ + transform, + onCommit, + onPreview, + t, +}) => { + const { offset, rotation, scale } = SPRITE_CONSTRAINTS; + const commitField = (patch: Partial) => + onCommit({ ...transform, ...patch }); + const previewField = onPreview + ? (patch: Partial) => onPreview({ ...transform, ...patch }) + : undefined; + return ( + <> + + + commitField({ x: clamp(value, offset.min, offset.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ x: clamp(value, offset.min, offset.max) }) + : undefined + } + onCancel={() => editGestureController.cancel()} + prefix="X" + width={AXIS_FIELD_WIDTH} + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> + + commitField({ y: clamp(value, offset.min, offset.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ y: clamp(value, offset.min, offset.max) }) + : undefined + } + onCancel={() => editGestureController.cancel()} + prefix="Y" + width={AXIS_FIELD_WIDTH} + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> + + + + commitField({ rotation: clamp(value, rotation.min, rotation.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ + rotation: clamp(value, rotation.min, rotation.max), + }) + : undefined + } + onCancel={() => editGestureController.cancel()} + suffix="°" + width={AXIS_FIELD_WIDTH} + min={rotation.min} + max={rotation.max} + allowDecimal + decimalScale={1} + /> + + + + commitField({ scale: clamp(value, scale.min, scale.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ scale: clamp(value, scale.min, scale.max) }) + : undefined + } + onCancel={() => editGestureController.cancel()} + suffix="×" + width={AXIS_FIELD_WIDTH} + min={scale.min} + max={scale.max} + allowDecimal + decimalScale={2} + step={0.1} + /> + + + ); +}; + +interface SingleSpritePanelProps { + setPanelElement: (el: HTMLDivElement | null) => void; + singleSpritePosition: CanonicalReactiveSpritePosition; + selectedKeyType: string; + isRenaming: boolean; + renameInputRef: React.RefObject; + renameValue: string; + setRenameValue: (value: string) => void; + renameCancelledRef: React.MutableRefObject; + handleRenameCommit: (value: string) => void; + handleRenameCancel: () => void; + handleRenameStart: () => void; + singleScrollRefFor: (tab: TabType) => (node: HTMLDivElement | null) => void; + t: (key: string) => string; +} + +export const SingleSpritePanel: React.FC = ({ + setPanelElement, + singleSpritePosition, + selectedKeyType, + isRenaming, + renameInputRef, + renameValue, + setRenameValue, + renameCancelledRef, + handleRenameCommit, + handleRenameCancel, + handleRenameStart, + singleScrollRefFor, + t, +}) => { + const position = singleSpritePosition; + const keyMappings = useKeyStore((state) => state.keyMappings); + const canonicalKeyPositions = useKeyStore( + (state) => state.canonicalPositions, + ); + const loadingImageRef = useRef(false); + + // 무효 자세(빈/중복 트리거)는 백엔드가 거부하므로 커밋 착지 전까지 패널이 값을 들고 있는다 + const [posesDraft, setPosesDraft] = useState<{ + id: string; + poses: SpritePose[]; + } | null>(null); + + // draft는 커밋이 canonical에 착지하거나 대상이 바뀔 때 지운다 + useEffect(() => { + setPosesDraft((current) => { + if (!current) return current; + if (current.id !== position.id) return null; + const canonical = useSpriteStore + .getState() + .positions[selectedKeyType]?.find( + (sprite) => sprite.id === position.id, + ); + if (!canonical) return current; + return JSON.stringify(canonical.poses) === JSON.stringify(current.poses) + ? null + : current; + }); + }, [position.id, position.poses, selectedKeyType]); + + // 담당 키 후보: 현재 모드의 키 요소 목록 (값은 요소 id, 라벨은 슬롯 표시명) + const modeSlots = keyMappings[selectedKeyType] ?? []; + const keyOptions = (canonicalKeyPositions[selectedKeyType] ?? []).flatMap( + (keyPosition, index) => { + if (!keyPosition.id) return []; + const label = + slotDisplayName(modeSlots[index] ?? '') || `Key ${index + 1}`; + return [{ id: keyPosition.id, label }]; + }, + ); + const keyOptionIds = new Set(keyOptions.map((option) => option.id)); + + const displayPoses = + posesDraft && posesDraft.id === position.id + ? posesDraft.poses + : position.poses; + const duplicatePose = findDuplicateTriggerPose(displayPoses); + const hasEmptyTriggerPose = displayPoses.some( + (pose) => pose.triggers.length === 0, + ); + const posesCommittable = duplicatePose === null && !hasEmptyTriggerPose; + + // 전체 필드 패치 커밋: 다음 spritePositions 레코드를 구성해 editor_commit으로. + // 활성 preview 게스처가 있으면 gestureId를 실어 정산한다 + const commitFields = (patch: Partial) => { + const canonical = useSpriteStore.getState().positions; + const modePositions = canonical[selectedKeyType] ?? []; + const index = modePositions.findIndex( + (sprite) => sprite.id === position.id, + ); + if (index < 0) return; + const nextMode = [...modePositions]; + nextMode[index] = { ...nextMode[index], ...patch }; + const next = { ...canonical, [selectedKeyType]: nextMode }; + const gestureId = editGestureController.activeGestureId() ?? undefined; + const persisted = spriteItemsApi.updatePositions(next, gestureId); + editGestureController.settleCommit(persisted); + void persisted.catch((error) => { + console.error('Failed to update sprite', error); + }); + }; + + // 스크럽·타이핑 실시간 프리뷰 (spritePosition 도메인) + const previewFields = (patch: Record) => { + if (!isNativeElementId(position.id)) return; + const locator = resolveElementById('sprite', position.id); + if (!locator) return; + editGestureController.preview(locator.mode, [{ id: position.id, patch }], { + domain: 'spritePosition', + }); + }; + + const updatePoses = (nextPoses: SpritePose[]) => { + setPosesDraft({ id: position.id, poses: nextPoses }); + const blocked = + nextPoses.some((pose) => pose.triggers.length === 0) || + findDuplicateTriggerPose(nextPoses) !== null; + if (blocked) { + // 열린 preview 게스처에 무효 poses가 실려 커밋 경계로 승격되지 않게 닫는다 + editGestureController.cancel(); + return; + } + commitFields({ poses: nextPoses }); + }; + + const replacePose = (poseIndex: number, patch: Partial) => + updatePoses( + displayPoses.map((pose, index) => + index === poseIndex ? { ...pose, ...patch } : pose, + ), + ); + + const togglePoseTrigger = (poseIndex: number, keyId: string) => { + const pose = displayPoses[poseIndex]; + if (!pose) return; + const nextTriggers = pose.triggers.includes(keyId) + ? pose.triggers.filter((id) => id !== keyId) + : [...pose.triggers, keyId]; + replacePose(poseIndex, { triggers: nextTriggers }); + }; + + const addPose = () => { + if (displayPoses.length >= SPRITE_CONSTRAINTS.maxPoses) return; + const pose: SpritePose = { + poseId: crypto.randomUUID(), + triggers: [], + matchMode: 'exact', + transform: { ...position.idleTransform }, + imageOverride: null, + }; + updatePoses([...displayPoses, pose]); + }; + + const removePose = (poseIndex: number) => + updatePoses(displayPoses.filter((_, index) => index !== poseIndex)); + + const showInvalidImageAlert = (): void => { + void window.api.ui.dialog + .alert(t('imagePicker.invalidImage'), { + confirmText: t('common.ok') || '확인', + }) + .catch((error) => { + console.error('Failed to open invalid image alert:', error); + }); + }; + + // 키 패널과 동일한 이미지 선택 흐름 (image_load + 디코드 확인) + const pickImage = async (): Promise => { + if (loadingImageRef.current) return null; + loadingImageRef.current = true; + try { + const result = await imageApi.load(); + if (!result?.success || !result.imagePath) { + // errorCode가 없는 실패는 사용자 취소 + if (result?.errorCode) showInvalidImageAlert(); + return null; + } + // 시그니처를 통과해도 WebView가 못 그리는 파일이 있다. 직전 값을 덮기 전에 확인한다 + if (!(await canDecodeImage(result.imagePath))) { + showInvalidImageAlert(); + return null; + } + return result.imagePath; + } catch (error) { + console.error('Failed to load image', error); + return null; + } finally { + loadingImageRef.current = false; + } + }; + + const handleBaseImageSelect = async () => { + const path = await pickImage(); + if (path) commitFields({ baseImage: path }); + }; + + const handlePoseImageSelect = async (poseIndex: number) => { + const path = await pickImage(); + if (path) replacePose(poseIndex, { imageOverride: path }); + }; + + const spriteTitle = position.layerName || 'Sprite'; + const { anchor, transitionMs } = SPRITE_CONSTRAINTS; + const easingValue = + position.transitionEasing || DEFAULT_SPRITE_TRANSITION_EASING; + const easingOptions = SPRITE_EASING_PRESETS.some( + (preset) => preset.value === easingValue, + ) + ? [...SPRITE_EASING_PRESETS] + : [ + { + label: t('propertiesPanel.spriteEasingCustom') || '사용자 정의', + value: easingValue, + }, + ...SPRITE_EASING_PRESETS, + ]; + + const pivotPercent = (value: number) => Math.round(value * 1000) / 10; + + return ( +
+
+ {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={() => { + if (!renameCancelledRef.current) { + handleRenameCommit(renameValue); + } + renameCancelledRef.current = false; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleRenameCancel(); + } + }} + /> + ) : ( +
+ + {spriteTitle} + + +
+ )} +
+ +
+
+ + {/* 이미지 */} + + + + {position.baseImage ? ( + + ) : null} + + + + + commitFields({ imageFit: value as SpriteImageFit }) + } + /> + + + + + commitFields({ + imageRect: { ...position.imageRect, x: value }, + }) + } + onPreview={(value) => + previewFields({ + imageRect: { ...position.imageRect, x: value }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="X" + width={AXIS_FIELD_WIDTH} + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> + + commitFields({ + imageRect: { ...position.imageRect, y: value }, + }) + } + onPreview={(value) => + previewFields({ + imageRect: { ...position.imageRect, y: value }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="Y" + width={AXIS_FIELD_WIDTH} + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> + + + + + commitFields({ + imageRect: { + ...position.imageRect, + width: Math.max(1, value), + }, + }) + } + onPreview={(value) => + previewFields({ + imageRect: { + ...position.imageRect, + width: Math.max(1, value), + }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="W" + width={AXIS_FIELD_WIDTH} + min={1} + max={9999} + allowDecimal + decimalScale={1} + /> + + commitFields({ + imageRect: { + ...position.imageRect, + height: Math.max(1, value), + }, + }) + } + onPreview={(value) => + previewFields({ + imageRect: { + ...position.imageRect, + height: Math.max(1, value), + }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="H" + width={AXIS_FIELD_WIDTH} + min={1} + max={9999} + allowDecimal + decimalScale={1} + /> + + + {/* 정규화 좌표를 %로 표시 */} + + + commitFields({ + pivot: { + ...position.pivot, + x: clamp(value, 0, 100) / 100, + }, + }) + } + onPreview={(value) => + previewFields({ + pivot: { + ...position.pivot, + x: clamp(value, 0, 100) / 100, + }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="X" + suffix="%" + width={AXIS_FIELD_WIDTH} + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> + + commitFields({ + pivot: { + ...position.pivot, + y: clamp(value, 0, 100) / 100, + }, + }) + } + onPreview={(value) => + previewFields({ + pivot: { + ...position.pivot, + y: clamp(value, 0, 100) / 100, + }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="Y" + suffix="%" + width={AXIS_FIELD_WIDTH} + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> + + + + {/* 대기 자세 */} + +
+

+ {t('propertiesPanel.spriteIdlePose') || '대기 자세'} +

+
+ commitFields({ idleTransform: next })} + onPreview={(next) => previewFields({ idleTransform: next })} + t={t} + /> +
+ + {/* 자세 목록: 자세마다 담당 키 + 변환 + 이미지 교체 */} + {displayPoses.map((pose, poseIndex) => { + const isDuplicate = duplicatePose?.poseId === pose.poseId; + const isEmpty = pose.triggers.length === 0; + const deadTriggers = pose.triggers.filter( + (id) => !keyOptionIds.has(id), + ); + return ( + +
+

+ {`${t('propertiesPanel.spritePose') || '자세'} ${ + poseIndex + 1 + }`} +

+ +
+ +
+

+ {t('propertiesPanel.spriteTriggerKeys') || '담당 키'} +

+ {keyOptions.length === 0 && deadTriggers.length === 0 ? ( +

+ {t('propertiesPanel.spriteNoKeys') || + '이 모드에 키 요소가 없습니다'} +

+ ) : ( +
+ {keyOptions.map((option) => { + const selected = pose.triggers.includes(option.id); + return ( + + ); + })} + {/* 죽은 참조는 표시하고 클릭으로 제거 */} + {deadTriggers.map((id) => ( + + ))} +
+ )} + {isEmpty || isDuplicate ? ( +

+ {isEmpty + ? t('propertiesPanel.spriteEmptyTriggers') || + '담당 키를 선택하면 저장됩니다' + : t('propertiesPanel.spriteDuplicateTriggers') || + '같은 키 조합의 자세가 있어 저장되지 않습니다'} +

+ ) : null} +
+ + + replacePose(poseIndex, { transform: next }) + } + onPreview={ + posesCommittable + ? (next) => + previewFields({ + poses: displayPoses.map((entry, index) => + index === poseIndex + ? { ...entry, transform: next } + : entry, + ), + }) + : undefined + } + t={t} + /> + + + + {pose.imageOverride ? ( + + ) : null} + +
+ ); + })} + + + + {/* 전환 */} + + + + commitFields({ + transitionMs: clamp( + value, + transitionMs.min, + transitionMs.max, + ), + }) + } + onPreview={(value) => + previewFields({ + transitionMs: clamp( + value, + transitionMs.min, + transitionMs.max, + ), + }) + } + onCancel={() => editGestureController.cancel()} + suffix="ms" + min={transitionMs.min} + max={transitionMs.max} + /> + + + + + commitFields({ transitionEasing: value }) + } + align="right" + widthClass="w-[110px]" + /> + + +
+
+
+
+ ); +}; + +export default SingleSpritePanel; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/types.ts b/src/renderer/components/main/Grid/PropertiesPanel/types.ts index 4dd05a884..bc93c797b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/types.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/types.ts @@ -244,7 +244,7 @@ export interface KeyData { // ============================================================================ export interface LayerItem { - type: 'key' | 'stat' | 'graph' | 'knob' | 'plugin'; + type: 'key' | 'stat' | 'graph' | 'knob' | 'sprite' | 'plugin'; id: string; index?: number; name: string; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index a3fb72a63..64b83e733 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -255,7 +255,8 @@ "addKey": "Key", "addStat": "Stat", "addGraph": "Graph", - "addKnob": "Knob" + "addKnob": "Knob", + "addSprite": "Sprite" }, "preset": { "import": "Import", @@ -336,7 +337,9 @@ "duplicateGraph": "Duplicate Graph", "deleteGraph": "Delete Graph", "duplicateKnob": "Duplicate Knob", + "duplicateSprite": "Duplicate Sprite", "deleteKnob": "Delete Knob", + "deleteSprite": "Delete Sprite", "noteSetting": "Note Setting", "counterSetting": "Counter Setting", "counterReset": "Reset Counter", @@ -355,6 +358,7 @@ "addStat": "Add Stat", "addGraph": "Add Graph", "addKnob": "Add Knob", + "addSprite": "Add Sprite", "rename": "Rename", "quitApp": "Quit Application", "snapToEdge": "Snap to Edge", @@ -546,6 +550,27 @@ "knobCapturing": "Turn the knob", "knobSensitivity": "Sensitivity", "knobReverse": "Reverse Direction", + "spriteBaseImage": "Base Image", + "spriteImageSelect": "Select", + "spriteImageRemove": "Remove", + "spriteImageRect": "Image Position", + "spriteImageSize": "Image Size", + "spritePivot": "Pivot", + "spriteIdlePose": "Idle Pose", + "spriteRotation": "Rotation", + "spriteScale": "Scale", + "spritePose": "Pose", + "spriteAddPose": "Add Pose", + "spriteRemovePose": "Delete Pose", + "spriteTriggerKeys": "Trigger Keys", + "spriteImageOverride": "Pose Image", + "spriteMissingKey": "Deleted key", + "spriteNoKeys": "No key elements in this mode", + "spriteEmptyTriggers": "Select trigger keys to save this pose", + "spriteDuplicateTriggers": "A pose with the same key set already exists", + "spriteTransition": "Transition", + "spriteEasing": "Easing", + "spriteEasingCustom": "Custom", "graphSpeed": "Graph Speed", "graphColor": "Graph Color", "graphAnimation": "Graph Motion", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index b5fdc6b3c..1a8b28c20 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -255,7 +255,8 @@ "addKey": "키", "addStat": "통계", "addGraph": "그래프", - "addKnob": "노브" + "addKnob": "노브", + "addSprite": "스프라이트" }, "preset": { "import": "불러오기", @@ -336,7 +337,9 @@ "duplicateGraph": "그래프 복제", "deleteGraph": "그래프 삭제", "duplicateKnob": "노브 복제", + "duplicateSprite": "스프라이트 복제", "deleteKnob": "노브 삭제", + "deleteSprite": "스프라이트 삭제", "noteSetting": "노트 설정", "counterSetting": "카운터 설정", "counterReset": "카운터 초기화", @@ -355,6 +358,7 @@ "addStat": "통계 추가", "addGraph": "그래프 추가", "addKnob": "노브 추가", + "addSprite": "스프라이트 추가", "rename": "이름 변경", "quitApp": "프로그램 종료", "snapToEdge": "화면 모서리에 맞추기", @@ -546,6 +550,27 @@ "knobCapturing": "노브를 돌리세요", "knobSensitivity": "민감도", "knobReverse": "방향 반전", + "spriteBaseImage": "기본 이미지", + "spriteImageSelect": "선택", + "spriteImageRemove": "제거", + "spriteImageRect": "이미지 위치", + "spriteImageSize": "이미지 크기", + "spritePivot": "기준점", + "spriteIdlePose": "대기 자세", + "spriteRotation": "회전", + "spriteScale": "배율", + "spritePose": "자세", + "spriteAddPose": "자세 추가", + "spriteRemovePose": "자세 삭제", + "spriteTriggerKeys": "담당 키", + "spriteImageOverride": "자세 이미지", + "spriteMissingKey": "삭제된 키", + "spriteNoKeys": "이 모드에 키 요소가 없습니다", + "spriteEmptyTriggers": "담당 키를 선택하면 저장됩니다", + "spriteDuplicateTriggers": "같은 키 조합의 자세가 있어 저장되지 않습니다", + "spriteTransition": "전환 시간", + "spriteEasing": "가속 곡선", + "spriteEasingCustom": "사용자 정의", "graphSpeed": "그래프 속도", "graphColor": "그래프 색상", "graphAnimation": "그래프 모션", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 31c4c8f23..59c8866ad 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -255,7 +255,8 @@ "addKey": "Клавиша", "addStat": "Стат.", "addGraph": "График", - "addKnob": "Ручка" + "addKnob": "Ручка", + "addSprite": "Спрайт" }, "preset": { "import": "Импорт", @@ -336,7 +337,9 @@ "duplicateGraph": "Дублировать график", "deleteGraph": "Удалить график", "duplicateKnob": "Дублировать ручку", + "duplicateSprite": "Дублировать спрайт", "deleteKnob": "Удалить ручку", + "deleteSprite": "Удалить спрайт", "noteSetting": "Настр. ноты", "counterSetting": "Настр. счётчика", "counterReset": "Сброс счётчика", @@ -355,6 +358,7 @@ "addStat": "Добавить стат.", "addGraph": "Добавить график", "addKnob": "Добавить ручку", + "addSprite": "Добавить спрайт", "rename": "Переименовать", "quitApp": "Выйти из приложения", "snapToEdge": "Привязать к краю", @@ -551,6 +555,27 @@ "knobCapturing": "Поверните ручку", "knobSensitivity": "Чувствительность", "knobReverse": "Обратное направление", + "spriteBaseImage": "Базовая картинка", + "spriteImageSelect": "Выбрать", + "spriteImageRemove": "Убрать", + "spriteImageRect": "Позиция картинки", + "spriteImageSize": "Размер картинки", + "spritePivot": "Точка опоры", + "spriteIdlePose": "Поза покоя", + "spriteRotation": "Поворот", + "spriteScale": "Масштаб", + "spritePose": "Поза", + "spriteAddPose": "Добавить позу", + "spriteRemovePose": "Удалить позу", + "spriteTriggerKeys": "Клавиши позы", + "spriteImageOverride": "Картинка позы", + "spriteMissingKey": "Удалённая клавиша", + "spriteNoKeys": "В этом режиме нет клавиш", + "spriteEmptyTriggers": "Выберите клавиши, чтобы сохранить позу", + "spriteDuplicateTriggers": "Поза с таким набором клавиш уже есть", + "spriteTransition": "Переход", + "spriteEasing": "Сглаживание", + "spriteEasingCustom": "Настраиваемый", "customImage": "Своя картинка", "configure": "Настроить", "className": "Класс", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 6cae05d83..6abab22c5 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -255,7 +255,8 @@ "addKey": "按鍵", "addStat": "統計", "addGraph": "圖表", - "addKnob": "旋鈕" + "addKnob": "旋鈕", + "addSprite": "精靈圖" }, "preset": { "import": "導入", @@ -336,7 +337,9 @@ "duplicateGraph": "複製圖表", "deleteGraph": "刪除圖表", "duplicateKnob": "複製旋鈕", + "duplicateSprite": "複製精靈圖", "deleteKnob": "刪除旋鈕", + "deleteSprite": "刪除精靈圖", "noteSetting": "音符設定", "counterSetting": "計數器設定", "counterReset": "重置計數器", @@ -355,6 +358,7 @@ "addStat": "新增統計", "addGraph": "新增圖表", "addKnob": "新增旋鈕", + "addSprite": "新增精靈圖", "rename": "重新命名", "quitApp": "退出程式", "snapToEdge": "貼靠螢幕邊緣", @@ -551,6 +555,27 @@ "knobCapturing": "轉動旋鈕", "knobSensitivity": "靈敏度", "knobReverse": "反轉方向", + "spriteBaseImage": "基礎圖像", + "spriteImageSelect": "選擇", + "spriteImageRemove": "移除", + "spriteImageRect": "圖像位置", + "spriteImageSize": "圖像大小", + "spritePivot": "基準點", + "spriteIdlePose": "待機姿勢", + "spriteRotation": "旋轉", + "spriteScale": "縮放", + "spritePose": "姿勢", + "spriteAddPose": "新增姿勢", + "spriteRemovePose": "刪除姿勢", + "spriteTriggerKeys": "觸發按鍵", + "spriteImageOverride": "姿勢圖像", + "spriteMissingKey": "已刪除的按鍵", + "spriteNoKeys": "此模式沒有按鍵元素", + "spriteEmptyTriggers": "選擇觸發按鍵後才會儲存", + "spriteDuplicateTriggers": "已存在相同按鍵組合的姿勢", + "spriteTransition": "過渡時間", + "spriteEasing": "緩動曲線", + "spriteEasingCustom": "自訂", "customImage": "自定義圖像", "configure": "配置", "className": "類名", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 0ab7cf428..da2f4ca9c 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -255,7 +255,8 @@ "addKey": "按键", "addStat": "统计", "addGraph": "图表", - "addKnob": "旋钮" + "addKnob": "旋钮", + "addSprite": "精灵图" }, "preset": { "import": "导入", @@ -336,7 +337,9 @@ "duplicateGraph": "复制图表", "deleteGraph": "删除图表", "duplicateKnob": "复制旋钮", + "duplicateSprite": "复制精灵图", "deleteKnob": "删除旋钮", + "deleteSprite": "删除精灵图", "noteSetting": "音符设置", "counterSetting": "计数器设置", "counterReset": "重置计数器", @@ -355,6 +358,7 @@ "addStat": "添加统计", "addGraph": "添加图表", "addKnob": "添加旋钮", + "addSprite": "添加精灵图", "rename": "重命名", "quitApp": "退出程序", "snapToEdge": "贴靠屏幕边缘", @@ -551,6 +555,27 @@ "knobCapturing": "转动旋钮", "knobSensitivity": "灵敏度", "knobReverse": "反转方向", + "spriteBaseImage": "基础图像", + "spriteImageSelect": "选择", + "spriteImageRemove": "移除", + "spriteImageRect": "图像位置", + "spriteImageSize": "图像大小", + "spritePivot": "基准点", + "spriteIdlePose": "待机姿势", + "spriteRotation": "旋转", + "spriteScale": "缩放", + "spritePose": "姿势", + "spriteAddPose": "添加姿势", + "spriteRemovePose": "删除姿势", + "spriteTriggerKeys": "触发按键", + "spriteImageOverride": "姿势图像", + "spriteMissingKey": "已删除的按键", + "spriteNoKeys": "此模式没有按键元素", + "spriteEmptyTriggers": "选择触发按键后才会保存", + "spriteDuplicateTriggers": "已存在相同按键组合的姿势", + "spriteTransition": "过渡时间", + "spriteEasing": "缓动曲线", + "spriteEasingCustom": "自定义", "customImage": "自定义图像", "configure": "配置", "className": "类名", From b1ef61f9d29bff62ea166e75d8fbc2d7f35b3ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 31 Aug 2026 06:51:34 +0900 Subject: [PATCH 007/125] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=90=EC=84=B8=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=EA=B3=BC=20=EB=8F=84=EB=8B=AC=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B0=BD=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/editor/page.mdx | 59 +- docs/content/en/api-reference/keys/page.mdx | 16 +- .../content/en/api-reference/presets/page.mdx | 34 +- docs/content/ko/api-reference/editor/page.mdx | 41 +- docs/content/ko/api-reference/keys/page.mdx | 16 +- .../content/ko/api-reference/presets/page.mdx | 58 +- src-tauri/src/models/mod.rs | 24 +- .../api/modules/editorAdapters.test.ts | 118 +- src/renderer/api/modules/itemsApi.ts | 49 +- .../single/SingleSpritePanel.test.tsx | 1203 ++++++++++++++++- .../single/SingleSpritePanel.tsx | 1167 +++++++++------- .../single/SpriteImageSettingsPopup.tsx | 212 +++ .../single/SpritePoseEditorPopup.tsx | 252 ++++ .../Grid/layers/SpriteItem.render.test.tsx | 227 ++++ .../main/Grid/layers/SpriteItem.tsx | 82 +- .../components/main/Modal/PopupExit.test.tsx | 31 + .../components/main/Modal/PopupExit.tsx | 4 +- .../Modal/content/pickers/ImagePicker.tsx | 173 ++- .../components/main/common/Dropdown.test.tsx | 61 + .../components/main/common/Dropdown.tsx | 144 +- .../main/common/TransformGlyphs.tsx | 39 + .../overlay/counters/OverlaySpriteItem.tsx | 23 +- .../counters/overlaySpriteItem.test.tsx | 65 +- .../components/shared/OverlayScene.tsx | 12 +- .../runtime/editorStateCoordinator.test.ts | 8 +- .../editor/runtime/editorStateCoordinator.ts | 7 + src/renderer/editor/runtime/elementOps.ts | 8 +- .../spritePatchOptimisticIntegration.test.ts | 195 +++ .../hooks/Grid/useGridCanvasActions.test.tsx | 5 +- .../hooks/Grid/useGridCanvasActions.ts | 48 +- .../hooks/Grid/useGridSelection.test.tsx | 70 + src/renderer/hooks/Grid/useGridSelection.ts | 32 +- .../hooks/shared/useLayoutComputation.test.ts | 140 ++ .../hooks/shared/useLayoutComputation.ts | 55 +- src/renderer/locales/en.json | 21 +- src/renderer/locales/ko.json | 21 +- src/renderer/locales/ru.json | 21 +- src/renderer/locales/zh-Hant.json | 21 +- src/renderer/locales/zh-cn.json | 21 +- .../grid/useSpriteEditPreviewStore.test.ts | 85 ++ .../stores/grid/useSpriteEditPreviewStore.ts | 68 + src/renderer/styles/global.css | 13 + src/renderer/utils/cardRecipes.ts | 4 + src/renderer/utils/layerGroupUtils.ts | 12 +- src/renderer/utils/sprite/poseIdentity.ts | 11 + .../utils/sprite/spriteImageStyles.ts | 48 + src/renderer/utils/sprite/spriteReach.test.ts | 204 +++ src/renderer/utils/sprite/spriteReach.ts | 234 ++++ .../utils/sprite/spriteWireShape.test.ts | 113 ++ src/renderer/utils/sprite/spriteWireShape.ts | 33 + .../overlay/App.spritePreview.test.tsx | 29 +- src/renderer/windows/overlay/App.tsx | 37 +- src/types/key/sprites.test.ts | 92 ++ src/types/key/sprites.ts | 11 +- 54 files changed, 4800 insertions(+), 977 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx create mode 100644 src/renderer/components/main/common/TransformGlyphs.tsx create mode 100644 src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts create mode 100644 src/renderer/hooks/shared/useLayoutComputation.test.ts create mode 100644 src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts create mode 100644 src/renderer/stores/grid/useSpriteEditPreviewStore.ts create mode 100644 src/renderer/utils/sprite/spriteImageStyles.ts create mode 100644 src/renderer/utils/sprite/spriteReach.test.ts create mode 100644 src/renderer/utils/sprite/spriteReach.ts create mode 100644 src/renderer/utils/sprite/spriteWireShape.test.ts create mode 100644 src/renderer/utils/sprite/spriteWireShape.ts create mode 100644 src/types/key/sprites.test.ts diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index 700896ebe..39b5278e1 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -11,7 +11,7 @@ description: Atomic editor document reads, commits, and change events document. -`dmn.editor` reads and updates the six collections that make up the editor +`dmn.editor` reads and updates the seven collections that make up the editor layout as one revisioned document. Use it when one user action changes more than one collection, or when a consumer needs a single ordered change stream. @@ -24,6 +24,7 @@ type EditorField = | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'spritePositions' | 'layerGroups'; interface EditorDocumentV1 { @@ -33,6 +34,7 @@ interface EditorDocumentV1 { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; layerGroups: LayerGroups; } @@ -47,20 +49,21 @@ that top-level collection. It is not an item-level diff. Since the multi-key update, `keys` entries in `EditorDocumentV1` (returned by `get()`) and in `EditorPatchV1` (delivered by `onCommitted()`) use the - `KeySlot` union (`string | MultiKeySlot`), not plain strings. See the - [Keys API](/docs/api-reference/keys) for the slot shape and canonical - identifier rules. + `KeySlot` union (`string | MultiKeySlot`), not plain strings. See the [Keys + API](/docs/api-reference/keys) for the slot shape and canonical identifier + rules. Every element in the position collections (`keyPositions`, `statPositions`, - `graphPositions`, `knobPositions`) carries a stable `id` (UUID) assigned and - owned by the app. Treat it as opaque: echo back the value you read and never - invent one. A written element with a missing or unknown `id` gets a fresh one - from the backend. If the same `id` appears more than once, for example when - you copy an element you read and append it, the element in its original slot - keeps the identity and the remaining copies get fresh ones. See the - [Keys API](/docs/api-reference/keys) for the identity rules. + `graphPositions`, `knobPositions`, `spritePositions`) carries a stable `id` + (UUID) assigned and owned by the app. Treat it as opaque: echo back the value + you read and never invent one. A written element with a missing or unknown + `id` gets a fresh one from the backend. If the same `id` appears more than + once, for example when you copy an element you read and append it, the element + in its original slot keeps the identity and the remaining copies get fresh + ones. The `poseId` carried by each pose of a sprite element follows the same + rules. See the [Keys API](/docs/api-reference/keys) for the identity rules. ## Read the Current Document @@ -94,10 +97,10 @@ platform, while no application can guarantee recovery from hardware or firmware that violates those barriers. - The app's Undo/Redo path also restores the six editor collections, custom-tab - metadata, selected mode, counters, preset settings, and per-tab note settings - in one backend store transaction. This internal command is intentionally not - part of the public plugin API. + The app's Undo/Redo path also restores the seven editor collections, + custom-tab metadata, selected mode, counters, preset settings, and per-tab + note settings in one backend store transaction. This internal command is + intentionally not part of the public plugin API. ```typescript @@ -218,19 +221,19 @@ interface EditorCommitError { } ``` -| Code | Meaning | `retryable` | -| ------------------------ | ------------------------------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | -| `PLUGIN_REVISION_CONFLICT` | The plugin-scoped revision is stale; read, reconcile, and commit again | `true` | -| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | -| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` | -| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` | -| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | -| `MULTI_KEY_UNSUPPORTED` | A `keys` write did not declare `multiKey` while multi-key slots exist | `false` | -| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | -| `IO_ERROR` | The document could not be persisted | `true` | -| `HISTORY_IN_PROGRESS` | An undo/redo barrier is in progress; retry after it settles | `true` | -| `HISTORY_EPOCH_CONFLICT` | The observed history epoch is stale; re-read and commit again | `true` | +| Code | Meaning | `retryable` | +| -------------------------- | ------------------------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision` is stale; read, reconcile, and commit again | `true` | +| `PLUGIN_REVISION_CONFLICT` | The plugin-scoped revision is stale; read, reconcile, and commit again | `true` | +| `VALIDATION_FAILED` | The completed document violates an editor validation rule | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds` exceeds 32 entries or the combined unique set exceeds 32 IDs | `false` | +| `INVALID_GESTURE_ID` | A gesture ID is not a UUID within the 64-byte limit | `false` | +| `PAIRED_UPDATE_REQUIRED` | A structural key change omitted its paired collection | `false` | +| `MULTI_KEY_UNSUPPORTED` | A `keys` write did not declare `multiKey` while multi-key slots exist | `false` | +| `MUTATION_ID_REUSED` | The same mutation ID was used for a different request | `false` | +| `IO_ERROR` | The document could not be persisted | `true` | +| `HISTORY_IN_PROGRESS` | An undo/redo barrier is in progress; retry after it settles | `true` | +| `HISTORY_EPOCH_CONFLICT` | The observed history epoch is stale; re-read and commit again | `true` | `details.currentRevision`, `details.validationCode`, `details.field`, or `details.currentHistoryEpoch` is included when it applies to the error. diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index 500db0079..bb02b4766 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -338,8 +338,18 @@ interface KeyPosition { id?: string; // stable element identity (UUID), assigned and owned by the app // ...position, image, note, and counter fields... imageMode?: 'replace' | 'overlay'; // image placement, defaults to replace (image replaces the key) - idleImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; // identity when absent - activeImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; + idleImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; // identity when absent + activeImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; noteOpacityTop?: number; noteOpacityBottom?: number; noteGradient?: GradientSpec | null; @@ -388,7 +398,7 @@ interface ElementShadowSpec { ``` Every element position (`keyPositions`, `statPositions`, `graphPositions`, -`knobPositions`) carries a stable `id`. Treat it as opaque: echo back the value +`knobPositions`, `spritePositions`) carries a stable `id`. Treat it as opaque: echo back the value you read and never invent one. A write whose `id` is missing or unknown gets a fresh one assigned by the backend. When the same `id` appears more than once, for example when you copy an element you read and append it, the element in diff --git a/docs/content/en/api-reference/presets/page.mdx b/docs/content/en/api-reference/presets/page.mdx index 00b3f60c5..61cfddbd8 100644 --- a/docs/content/en/api-reference/presets/page.mdx +++ b/docs/content/en/api-reference/presets/page.mdx @@ -23,9 +23,9 @@ Saves the current configuration to a preset file. Opens a file save dialog. ```javascript const result = await dmn.presets.save(); if (result.success) { - console.log("Preset saved"); + console.log('Preset saved'); } else { - console.error("Save failed:", result.error); + console.error('Save failed:', result.error); } ``` @@ -37,9 +37,9 @@ picker dialog. ```javascript const result = await dmn.presets.load(); if (result.success) { - console.log("Preset loaded"); + console.log('Preset loaded'); } else { - console.error("Load failed:", result.error); + console.error('Load failed:', result.error); } ``` @@ -79,54 +79,56 @@ interface PresetSnapshot { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; customTabs: CustomTab[]; selectedKeyType: string; tabNoteOverrides: TabNoteOverrides; } const unsub = dmn.presets.onSnapshot((snapshot) => { - console.log("Preset loaded:", snapshot.selectedKeyType); - console.log("Key mappings:", snapshot.keys); + console.log('Preset loaded:', snapshot.selectedKeyType); + console.log('Key mappings:', snapshot.keys); }); ``` - Individual change events (`onChanged`, `onPositionsChanged`, etc.) are not emitted during preset load. - Use `onSnapshot` to detect preset loads. + Individual change events (`onChanged`, `onPositionsChanged`, etc.) are not + emitted during preset load. Use `onSnapshot` to detect preset loads. --- + ## Example Usage ```javascript // @id preset-manager -if (dmn.window.type !== "main") return; +if (dmn.window.type !== 'main') return; // Save preset menu item dmn.ui.contextMenu.addGridMenuItem({ - id: "save-preset", - label: "Save Preset", + id: 'save-preset', + label: 'Save Preset', onClick: async () => { const result = await dmn.presets.save(); if (result.success) { - await dmn.ui.dialog.alert("Preset saved!"); + await dmn.ui.dialog.alert('Preset saved!'); } }, }); // Load preset menu item dmn.ui.contextMenu.addGridMenuItem({ - id: "load-preset", - label: "Load Preset", + id: 'load-preset', + label: 'Load Preset', onClick: async () => { const confirmed = await dmn.ui.dialog.confirm( - "This will overwrite the current configuration. Continue?" + 'This will overwrite the current configuration. Continue?', ); if (confirmed) { const result = await dmn.presets.load(); if (result.success) { - await dmn.ui.dialog.alert("Preset loaded!"); + await dmn.ui.dialog.alert('Preset loaded!'); } } }, diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 58be2949b..3f84aeb3e 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -11,7 +11,7 @@ description: 에디터 문서의 원자적 조회, 커밋, 변경 이벤트 API 있습니다. -`dmn.editor`는 에디터 레이아웃을 이루는 여섯 컬렉션을 revision이 있는 문서 +`dmn.editor`는 에디터 레이아웃을 이루는 일곱 컬렉션을 revision이 있는 문서 하나로 조회하고 갱신합니다. 사용자 동작 하나가 여러 컬렉션을 함께 바꾸거나, 소비자가 순서가 보장된 변경 흐름 하나를 받아야 할 때 사용하세요. @@ -24,6 +24,7 @@ type EditorField = | 'statPositions' | 'graphPositions' | 'knobPositions' + | 'spritePositions' | 'layerGroups'; interface EditorDocumentV1 { @@ -33,6 +34,7 @@ interface EditorDocumentV1 { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; layerGroups: LayerGroups; } @@ -53,12 +55,13 @@ type EditorPatchV1 = { 위치 컬렉션(`keyPositions`, `statPositions`, `graphPositions`, - `knobPositions`)의 모든 요소는 앱이 발급·소유하는 안정 `id`(UUID)를 - 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, 직접 - 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 값을 + `knobPositions`, `spritePositions`)의 모든 요소는 앱이 발급·소유하는 안정 + `id`(UUID)를 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, + 직접 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 값을 발급합니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 여러 번 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다. - 신원 규칙은 [Keys API](/docs/api-reference/keys)를 참고하세요. + 스프라이트 요소의 각 자세가 가진 `poseId`도 같은 규칙을 따릅니다. 신원 규칙은 + [Keys API](/docs/api-reference/keys)를 참고하세요. ## 현재 문서 조회 @@ -92,7 +95,7 @@ console.log(revision, document.keyPositions); 고장까지 애플리케이션이 절대 보장할 수는 없습니다. - 앱의 Undo/Redo도 편집 컬렉션 6개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋 + 앱의 Undo/Redo도 편집 컬렉션 7개, 커스텀 탭 정보, 선택 모드, 카운터, 프리셋 설정, 탭별 노트 설정을 백엔드 store 트랜잭션 한 번으로 함께 복원합니다. 이 내부 커맨드는 공개 플러그인 API로 노출하지 않습니다. @@ -212,19 +215,19 @@ interface EditorCommitError { } ``` -| 코드 | 의미 | `retryable` | -| ------------------------ | --------------------------------------------------------- | ----------- | -| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | -| `PLUGIN_REVISION_CONFLICT` | 플러그인 범위 revision이 오래됨. 다시 조회한 뒤 커밋 | `true` | -| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | -| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` | -| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` | -| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | -| `MULTI_KEY_UNSUPPORTED` | 멀티 키 슬롯이 있는데 `multiKey` 선언 없이 `keys`를 씀 | `false` | -| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | -| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | -| `HISTORY_IN_PROGRESS` | undo/redo 진행 중. 끝난 뒤 재시도 | `true` | -| `HISTORY_EPOCH_CONFLICT` | 관측한 history epoch이 오래됨. 다시 조회한 뒤 커밋 | `true` | +| 코드 | 의미 | `retryable` | +| -------------------------- | --------------------------------------------------------- | ----------- | +| `REVISION_CONFLICT` | `baseRevision`이 오래됨. 다시 조회·조정한 뒤 커밋 | `true` | +| `PLUGIN_REVISION_CONFLICT` | 플러그인 범위 revision이 오래됨. 다시 조회한 뒤 커밋 | `true` | +| `VALIDATION_FAILED` | 완성된 문서가 에디터 검증 규칙을 위반함 | `false` | +| `TOO_MANY_GESTURE_IDS` | `gestureIds`가 32개를 넘거나 합친 고유 ID가 32개를 초과함 | `false` | +| `INVALID_GESTURE_ID` | gesture ID가 UUID가 아니거나 64바이트를 초과함 | `false` | +| `PAIRED_UPDATE_REQUIRED` | 키 구조 변경에 짝 컬렉션이 빠짐 | `false` | +| `MULTI_KEY_UNSUPPORTED` | 멀티 키 슬롯이 있는데 `multiKey` 선언 없이 `keys`를 씀 | `false` | +| `MUTATION_ID_REUSED` | 같은 mutation ID를 다른 요청에 재사용함 | `false` | +| `IO_ERROR` | 문서를 디스크에 저장하지 못함 | `true` | +| `HISTORY_IN_PROGRESS` | undo/redo 진행 중. 끝난 뒤 재시도 | `true` | +| `HISTORY_EPOCH_CONFLICT` | 관측한 history epoch이 오래됨. 다시 조회한 뒤 커밋 | `true` | 오류에 해당할 때 `details.currentRevision`, `details.validationCode`, `details.field`, `details.currentHistoryEpoch`가 함께 제공됩니다. diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 9a5e6517a..6356efb8e 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -116,8 +116,18 @@ interface KeyPosition { activeTransparent?: boolean; idleTransparent?: boolean; imageMode?: 'replace' | 'overlay'; // 이미지 배치. 없으면 replace(이미지가 키를 대체) - idleImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; // 없으면 identity - activeImageTransform?: { offsetX: number; offsetY: number; rotation: number; scale: number }; + idleImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; // 없으면 identity + activeImageTransform?: { + offsetX: number; + offsetY: number; + rotation: number; + scale: number; + }; count: number; noteColor: string | { type: 'gradient'; top: string; bottom: string }; noteOpacity: number; @@ -178,7 +188,7 @@ interface ElementShadowSpec { ``` 모든 요소 위치(`keyPositions`, `statPositions`, `graphPositions`, -`knobPositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 +`knobPositions`, `spritePositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 백엔드가 새 값을 발급합니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해 덧붙이는 경우처럼) 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 diff --git a/docs/content/ko/api-reference/presets/page.mdx b/docs/content/ko/api-reference/presets/page.mdx index bf01777ca..41756da4f 100644 --- a/docs/content/ko/api-reference/presets/page.mdx +++ b/docs/content/ko/api-reference/presets/page.mdx @@ -16,9 +16,9 @@ description: 설정 프리셋 저장 및 불러오기 API ```javascript const result = await dmn.presets.save(); if (result.success) { - console.log("프리셋 저장 완료"); + console.log('프리셋 저장 완료'); } else { - console.error("저장 실패:", result.error); + console.error('저장 실패:', result.error); } ``` @@ -31,9 +31,9 @@ if (result.success) { ```javascript const result = await dmn.presets.load(); if (result.success) { - console.log("프리셋 불러오기 완료"); + console.log('프리셋 불러오기 완료'); } else { - console.error("불러오기 실패:", result.error); + console.error('불러오기 실패:', result.error); } ``` @@ -64,32 +64,32 @@ const result = await dmn.presets.loadTab(); ```javascript // @id preset-manager -if (dmn.window.type !== "main") return; +if (dmn.window.type !== 'main') return; // 프리셋 저장 메뉴 dmn.ui.contextMenu.addGridMenuItem({ - id: "save-preset", - label: "프리셋 저장", + id: 'save-preset', + label: '프리셋 저장', onClick: async () => { const result = await dmn.presets.save(); if (result.success) { - await dmn.ui.dialog.alert("프리셋이 저장되었습니다!"); + await dmn.ui.dialog.alert('프리셋이 저장되었습니다!'); } }, }); // 프리셋 불러오기 메뉴 dmn.ui.contextMenu.addGridMenuItem({ - id: "load-preset", - label: "프리셋 불러오기", + id: 'load-preset', + label: '프리셋 불러오기', onClick: async () => { const confirmed = await dmn.ui.dialog.confirm( - "현재 설정이 덮어씌워집니다. 계속하시겠습니까?" + '현재 설정이 덮어씌워집니다. 계속하시겠습니까?', ); if (confirmed) { const result = await dmn.presets.load(); if (result.success) { - await dmn.ui.dialog.alert("프리셋을 불러왔습니다!"); + await dmn.ui.dialog.alert('프리셋을 불러왔습니다!'); } } }, @@ -116,6 +116,7 @@ interface PresetSnapshot { statPositions: StatItemPositions; graphPositions: GraphItemPositions; knobPositions: KnobItemPositions; + spritePositions: SpritePositions; customTabs: CustomTab[]; selectedKeyType: string; tabNoteOverrides: TabNoteOverrides; @@ -124,34 +125,35 @@ interface PresetSnapshot { ```javascript const unsub = dmn.presets.onSnapshot((snapshot) => { - console.log("프리셋 로드됨:", snapshot.selectedKeyType); - console.log("키 매핑:", snapshot.keys); + console.log('프리셋 로드됨:', snapshot.selectedKeyType); + console.log('키 매핑:', snapshot.keys); }); ``` - 프리셋 로드 시 개별 이벤트(`onChanged`, `onPositionsChanged` 등)는 발행되지 않습니다. - 프리셋 로드를 감지하려면 `onSnapshot`을 사용하세요. + 프리셋 로드 시 개별 이벤트(`onChanged`, `onPositionsChanged` 등)는 발행되지 + 않습니다. 프리셋 로드를 감지하려면 `onSnapshot`을 사용하세요. ## 프리셋에 포함되는 항목 프리셋은 “전체 설정 덤프”가 아니라, 아래 항목들을 파일로 저장/복원합니다. -| 필드 | 설명 | -| --- | --- | -| `keys` | 키 매핑 | -| `positions` | 키 위치/크기/개별 옵션(클래스, 이미지, 카운터 설정 포함) | -| `customTabs` | 커스텀 탭 목록 | -| `selectedKeyType` | 선택된 탭/모드 | -| `backgroundColor` | 오버레이 배경색 | -| `noteSettings` / `noteEffect` | 노트 이펙트 관련 설정 | -| `laboratoryEnabled` | 실험실(랩) 기능 사용 여부 | -| `useCustomCSS` / `customCSS` | 커스텀 CSS 사용 여부/내용 | -| `useCustomJS` / `customJS` | 커스텀 JS 사용 여부/내용(플러그인 목록 포함) | +| 필드 | 설명 | +| ----------------------------- | -------------------------------------------------------- | +| `keys` | 키 매핑 | +| `positions` | 키 위치/크기/개별 옵션(클래스, 이미지, 카운터 설정 포함) | +| `customTabs` | 커스텀 탭 목록 | +| `selectedKeyType` | 선택된 탭/모드 | +| `backgroundColor` | 오버레이 배경색 | +| `noteSettings` / `noteEffect` | 노트 이펙트 관련 설정 | +| `laboratoryEnabled` | 실험실(랩) 기능 사용 여부 | +| `useCustomCSS` / `customCSS` | 커스텀 CSS 사용 여부/내용 | +| `useCustomJS` / `customJS` | 커스텀 JS 사용 여부/내용(플러그인 목록 포함) | - 플러그인 설정(`dmn.plugin.storage`)은 프리셋에 포함되지 않습니다. 플러그인 데이터를 포함하려면 별도로 관리하세요. + 플러그인 설정(`dmn.plugin.storage`)은 프리셋에 포함되지 않습니다. 플러그인 + 데이터를 포함하려면 별도로 관리하세요. diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index fd970c131..020a46607 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -971,6 +971,8 @@ pub enum SpriteActivation { pub struct SpritePose { #[serde(default, skip_serializing_if = "String::is_empty")] pub pose_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub triggers: Vec, #[serde(default)] pub match_mode: SpritePoseMatchMode, @@ -983,6 +985,7 @@ impl Default for SpritePose { fn default() -> Self { Self { pose_id: String::new(), + name: None, triggers: Vec::new(), match_mode: SpritePoseMatchMode::Exact, transform: SpriteTransform::default(), @@ -3266,8 +3269,8 @@ mod tests { compact_canonical_rgba, note_border_representative_hex, scrub_removed_text_outline_fields, FadePosition, GradientSpec, GraphPosition, GraphStatType, GraphType, KeyCounterAlign, KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyCounterSettings, KeyMappings, - KeyPosition, KeySlot, KnobPosition, NoteColor, NoteSettings, SlotMatch, StatPosition, - StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, + KeyPosition, KeySlot, KnobPosition, NoteColor, NoteSettings, SlotMatch, SpritePose, + StatPosition, StatType, MAX_SLOT_KEYS, POSITION_COLLECTION_FIELDS, }; use serde::Deserialize; @@ -3297,6 +3300,23 @@ mod tests { assert_eq!(serde_json::to_value(mappings).unwrap(), raw); } + #[test] + fn sprite_pose_name_round_trips_and_none_is_omitted() { + let pose = SpritePose { + name: Some("왼팔".to_string()), + ..SpritePose::default() + }; + + let serialized = serde_json::to_value(&pose).unwrap(); + assert_eq!(serialized["name"], "왼팔"); + + let restored: SpritePose = serde_json::from_value(serialized).unwrap(); + assert_eq!(restored.name.as_deref(), Some("왼팔")); + + let unnamed = serde_json::to_value(SpritePose::default()).unwrap(); + assert!(unnamed.get("name").is_none()); + } + #[test] fn legacy_700_weights_migrate_to_400_with_bold_modifier() { let mut raw = serde_json::to_value(KeyPosition::default()).unwrap(); diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts index f9c7e1700..b9813ab1a 100644 --- a/src/renderer/api/modules/editorAdapters.test.ts +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; -const { commitPatch } = vi.hoisted(() => ({ +const { commitPatch, commitGeneratedPatch } = vi.hoisted(() => ({ commitPatch: vi.fn(), + commitGeneratedPatch: vi.fn(), })); vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ - editorCoordinator: { commitPatch }, + editorCoordinator: { commitPatch, commitGeneratedPatch }, })); import { keysApi, updatePositionsWithGesture } from './keysApi'; @@ -18,7 +19,12 @@ import { statItemsApi, } from './itemsApi'; -import type { EditorDocumentV1 } from '@src/types/editor'; +import type { + CanonicalEditorDocumentV1, + CanonicalReactiveSpritePosition, + EditorDocumentV1, + EditorPatchV1, +} from '@src/types/editor'; import type { KeyPositions } from '@src/types/key/keys'; const document: EditorDocumentV1 = { @@ -32,10 +38,34 @@ const document: EditorDocumentV1 = { layerGroups: { '4key': [{ id: 'group-1', name: 'Group 1' }] }, }; +// 백엔드 실물 wire 형태: layerName·groupId는 None이면 직렬화에서 생략된다 +const spriteFixture = (id: string): CanonicalReactiveSpritePosition => ({ + id, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', +}); + describe('editor API compatibility adapters', () => { beforeEach(() => { commitPatch.mockReset(); commitPatch.mockResolvedValue(structuredClone(document)); + commitGeneratedPatch.mockReset(); + commitGeneratedPatch.mockResolvedValue(structuredClone(document)); }); it('routes key writers through editor_commit and preserves return shapes', async () => { @@ -83,7 +113,6 @@ describe('editor API compatibility adapters', () => { await expect(statItemsApi.updatePositions({})).resolves.toEqual({}); await expect(graphItemsApi.updatePositions({})).resolves.toEqual({}); await expect(knobItemsApi.updatePositions({})).resolves.toEqual({}); - await expect(spriteItemsApi.updatePositions({})).resolves.toEqual({}); await expect(layerGroupsApi.update(document.layerGroups)).resolves.toEqual( document.layerGroups, ); @@ -92,23 +121,86 @@ describe('editor API compatibility adapters', () => { [{ schemaVersion: 1, statPositions: {} }], [{ schemaVersion: 1, graphPositions: {} }], [{ schemaVersion: 1, knobPositions: {} }], - [{ schemaVersion: 1, spritePositions: {} }, undefined], [{ schemaVersion: 1, layerGroups: document.layerGroups }], ]); }); - it('sprite writer는 gestureId를 editor_commit 메타로 실어 보낸다', async () => { + it('sprite patchPosition은 슬롯 안 최신 base에서 대상만 패치해 생성한다', async () => { + const spriteA = spriteFixture('cccccccc-cccc-4ccc-8ccc-cccccccccccc'); + const spriteB = spriteFixture('dddddddd-dddd-4ddd-8ddd-dddddddddddd'); + const base = { + ...structuredClone(document), + spritePositions: { '4key': [spriteA, spriteB] }, + } as CanonicalEditorDocumentV1; + const generated: Array = []; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated.push(generate(base) as EditorPatchV1 | null); + return structuredClone(document); + }, + ); + + // patch가 명시 null layerName·groupId를 실어 와도 wire 형태로 정규화 await expect( - spriteItemsApi.updatePositions( - {}, + spriteItemsApi.patchPosition('4key', spriteA.id, { + baseImage: 'hand.png', + layerName: null, + groupId: null, + }), + ).resolves.toBe('committed'); + + expect(commitGeneratedPatch).toHaveBeenCalledWith( + expect.any(Function), + undefined, + ); + // 최신 base 기준으로 대상 필드만 갱신, 같은 모드 이웃은 그대로 + expect(generated[0]).toEqual({ + schemaVersion: 1, + spritePositions: { + '4key': [{ ...spriteA, baseImage: 'hand.png' }, spriteB], + }, + }); + const patched = (generated[0] as EditorPatchV1).spritePositions?.[ + '4key' + ]?.[0] as CanonicalReactiveSpritePosition; + expect('layerName' in patched).toBe(false); + expect('groupId' in patched).toBe(false); + }); + + it('sprite patchPosition은 대상 소실이면 무커밋 targetMissing이다', async () => { + const emptyBase = structuredClone(document) as CanonicalEditorDocumentV1; + const generated: Array = []; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated.push(generate(emptyBase) as EditorPatchV1 | null); + return structuredClone(document); + }, + ); + + await expect( + spriteItemsApi.patchPosition( + '4key', + 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + { baseImage: 'hand.png' }, + ), + ).resolves.toBe('targetMissing'); + expect(generated[0]).toBeNull(); + }); + + it('sprite patchPosition은 gestureId를 editor_commit 메타로 실어 보낸다', async () => { + // 수동 mock은 generate를 실행하지 않아 typed 결과는 targetMissing 유지 + await expect( + spriteItemsApi.patchPosition( + '4key', + 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + { hidden: true }, '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50', ), - ).resolves.toEqual({}); + ).resolves.toBe('targetMissing'); - expect(commitPatch).toHaveBeenCalledWith( - { schemaVersion: 1, spritePositions: {} }, - { gestureId: '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50' }, - ); + expect(commitGeneratedPatch).toHaveBeenCalledWith(expect.any(Function), { + gestureId: '6f9c2f6a-0b1d-4e5f-8a3c-2d7e9b4c1a50', + }); }); it('preserves each legacy caller return value when writes are queued together', async () => { diff --git a/src/renderer/api/modules/itemsApi.ts b/src/renderer/api/modules/itemsApi.ts index 6a0931dbb..30cc20e57 100644 --- a/src/renderer/api/modules/itemsApi.ts +++ b/src/renderer/api/modules/itemsApi.ts @@ -6,9 +6,16 @@ import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoord import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; -import type { SpritePositions } from '@src/types/key/sprites'; +import type { + ReactiveSpritePosition, + SpritePositions, +} from '@src/types/key/sprites'; +import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import type { LayerGroups } from '@src/types/layerGroups'; +// 커밋 성사 여부를 호출자가 판별하는 typed 결과. targetMissing은 무커밋 +export type SpritePatchCommitResult = 'committed' | 'targetMissing'; + export const statItemsApi = { getPositions: () => invoke('stat_positions_get'), updatePositions: (positions: StatItemPositions) => @@ -57,19 +64,41 @@ export const knobItemsApi = { export const spriteItemsApi = { getPositions: () => invoke('sprite_positions_get'), // 스프라이트 전용 필드는 ops patchElement가 거부하므로 전체 필드 패치로 커밋. - // gestureId는 숫자 스크럽 preview 게스처 정산용 - updatePositions: (positions: SpritePositions, gestureId?: string) => - enqueueEditorCompatibilityWrite( + // 호출 시점 캡처 전체 레코드는 동결 소유권 검사에 걸려 낙관 적용이 빠지므로 + // 직렬 슬롯 안 최신 base에서 대상 스프라이트만 패치해 생성한다. 대상 소실이면 + // 무커밋 targetMissing. gestureId는 숫자 스크럽 preview 게스처 정산용 + patchPosition: ( + mode: string, + id: string, + patch: Partial, + gestureId?: string, + ): Promise => { + let outcome: SpritePatchCommitResult = 'targetMissing'; + return enqueueEditorCompatibilityWrite( () => - editorCoordinator.commitPatch( - { - schemaVersion: 1, - spritePositions: positions, + editorCoordinator.commitGeneratedPatch( + (base) => { + const modePositions = base.spritePositions[mode] ?? []; + const index = modePositions.findIndex((sprite) => sprite.id === id); + if (index < 0) return null; + outcome = 'committed'; + const nextMode = [...modePositions]; + // patch가 명시 null layerName·groupId를 실어 와도 wire 형태 유지 + nextMode[index] = toSpriteWireShape({ + ...nextMode[index], + ...patch, + id, + }); + return { + schemaVersion: 1, + spritePositions: { ...base.spritePositions, [mode]: nextMode }, + }; }, gestureId ? { gestureId } : undefined, ), - () => structuredClone(positions), - ), + () => outcome, + ); + }, onPositionsChanged: (listener: (positions: SpritePositions) => void) => subscribe('spritePositions:changed', listener), }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index e1d36434f..3f5be3fd3 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -5,28 +5,94 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SingleSpritePanel } from './SingleSpritePanel'; import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useKeyStore } from '@stores/data/useKeyStore'; +import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; +class ResizeObserverStub { + observe() {} + disconnect() {} +} + const mocks = vi.hoisted(() => ({ - updatePositions: vi.fn((_positions: unknown, _gestureId?: string) => - Promise.resolve({}), + patchPosition: vi.fn( + (_mode: string, _id: string, _patch: unknown, _gestureId?: string) => + Promise.resolve(undefined), + ), + preview: vi.fn(), + gestureCancel: vi.fn(), + imageLoad: vi.fn(() => + Promise.resolve({ success: false } as { + success: boolean; + imagePath?: string; + errorCode?: string; + }), ), + canDecodeImage: vi.fn(() => Promise.resolve(true)), })); vi.mock('@api/modules/itemsApi', () => ({ - spriteItemsApi: { updatePositions: mocks.updatePositions }, + spriteItemsApi: { patchPosition: mocks.patchPosition }, })); vi.mock('@api/modules/resourceApi', () => ({ - imageApi: { load: vi.fn(() => Promise.resolve({ success: false })) }, + imageApi: { load: mocks.imageLoad }, +})); + +vi.mock('@utils/core/assetProbe', () => ({ + canDecodeImage: mocks.canDecodeImage, + canLoadFont: vi.fn(() => Promise.resolve(true)), })); vi.mock('@hooks/useLenis', () => ({ useLenis: () => ({ scrollContainerRef: vi.fn() }), })); +// 팝업 표면은 배치 로직 없이 열림 여부만 따라 인라인 렌더. +// 변형별 배치 계약(fallbackHeight/offsetY)은 data 속성으로 남겨 검증한다 +vi.mock('@components/main/Grid/PropertiesPanel/PickerSurface', () => ({ + default: ({ + open, + children, + ariaLabel, + fallbackHeight, + offsetY, + }: { + open: boolean; + children: React.ReactNode; + ariaLabel: string; + fallbackHeight: number; + offsetY?: number; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +// preview 발행 계약 검증용 - 패널이 쓰는 네 메서드만 대체 +vi.mock('@src/renderer/editor/runtime/editGestureController', () => ({ + editGestureController: { + preview: mocks.preview, + cancel: mocks.gestureCancel, + activeGestureId: () => undefined, + settleCommit: vi.fn(), + }, +})); + +// 프리뷰 locator가 테스트 환경의 빈 요소 맵에서도 해석되게 한다 +vi.mock('@src/renderer/editor/model/elementIdMap', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveElementById: () => ({ mode: '4key' }), +})); + const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const KEY_ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const KEY_ID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; @@ -97,12 +163,82 @@ describe('SingleSpritePanel 자세 편집', () => { (button) => button.textContent === text, )!; - const chipByTitle = (title: string) => - container.querySelector(`button[title="${title}"]`)!; + // 상태 행 - 피커 행 문법의 div[role=button], 클릭이 팝업을 연다 + const poseEditButtons = () => + [...container.querySelectorAll('[role="button"]')].filter( + (row) => + row.textContent?.startsWith('propertiesPanel.spritePose ') ?? false, + ); + + // 행 ⋮ 메뉴 열기 - 메뉴는 body 포털의 ListPopup + const openPoseRowMenu = (row: HTMLElement) => { + const moreButton = row.querySelector( + 'button[aria-label="common.more"]', + )!; + act(() => moreButton.click()); + }; + + const menuItemByText = (text: string) => + [...document.querySelectorAll('button')].find( + (button) => button.textContent === text, + )!; + + const setInputValue = (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }; + + // preview 발행은 rAF 뒤 setTimeout 0에 실린다 - 프레임과 두 틱을 기다린다 + const flushPreview = async () => { + await act(async () => { + await new Promise((resolve) => + window.requestAnimationFrame(() => resolve(null)), + ); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + }; + + const posePopup = () => + container.querySelector('[data-testid="pose-popup"]'); + + // 팝업 종류 구분 - 이미지 설정·상태 팝업이 같은 mock 표면을 쓴다 + const popupByLabel = (label: string) => + container.querySelector( + `[data-testid="pose-popup"][aria-label="${label}"]`, + ); + + // 담당 키 드롭다운 트리거는 요약 텍스트로 찾는다 + const triggerDropdownByText = (text: string) => + [ + ...container.querySelectorAll( + 'button[aria-haspopup="listbox"]', + ), + ].find((button) => button.textContent?.includes(text))!; + + const openListbox = () => + document.querySelector( + '[role="listbox"]:not([data-dmn-motion-state="closing"])', + ); + + // 메뉴는 body 포털로 뜨므로 document에서 옵션을 찾는다 + const menuOptionByText = (text: string) => + [ + ...document.querySelectorAll( + '[role="listbox"]:not([data-dmn-motion-state="closing"]) [role="option"]', + ), + ].find((option) => option.textContent === text)!; beforeEach(() => { vi.clearAllMocks(); - mocks.updatePositions.mockResolvedValue({}); + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + mocks.patchPosition.mockResolvedValue(undefined); + mocks.imageLoad.mockResolvedValue({ success: false }); + mocks.canDecodeImage.mockResolvedValue(true); useKeyStore.setState({ selectedKeyType: '4key', keyMappings: { '4key': ['A', 'S'] }, @@ -117,56 +253,69 @@ describe('SingleSpritePanel 자세 편집', () => { afterEach(() => { act(() => root.unmount()); + vi.unstubAllGlobals(); container.remove(); useSpriteStore.setState({ positions: {} }); + useSpriteEditPreviewStore.setState({ preview: null }); }); - it('자세 추가는 담당 키가 비어 커밋하지 않고 행만 표시한다', () => { + it('자세 추가는 담당 키가 비어 커밋하지 않고 요약 행과 편집 팝업만 연다', () => { const position = spritePosition(); seed(position); render(position); act(() => buttonByText('propertiesPanel.spriteAddPose').click()); - expect(mocks.updatePositions).not.toHaveBeenCalled(); + expect(mocks.patchPosition).not.toHaveBeenCalled(); expect(container.textContent).toContain('propertiesPanel.spritePose 1'); - expect(container.textContent).toContain( + // 빈 트리거 안내 문구는 제거됐다 - 요약 행은 자리표시 텍스트만 보인다 + expect(container.textContent).not.toContain( 'propertiesPanel.spriteEmptyTriggers', ); + // 새 자세는 편집 팝업이 바로 열려 담당 키 드롭다운이 보인다 + expect( + triggerDropdownByText('propertiesPanel.spriteTriggerPlaceholder'), + ).toBeTruthy(); }); - it('담당 키를 선택하면 전체 필드 패치로 커밋한다', () => { + it('담당 키를 선택하면 id 기반 필드 패치로 커밋한다', async () => { const position = spritePosition(); seed(position); render(position); act(() => buttonByText('propertiesPanel.spriteAddPose').click()); - act(() => chipByTitle('A').click()); - - expect(mocks.updatePositions).toHaveBeenCalledTimes(1); - const [rawRecord, gestureId] = mocks.updatePositions.mock.calls[0]; - const record = rawRecord as Record< - string, - CanonicalReactiveSpritePosition[] - >; + await act(async () => + triggerDropdownByText('propertiesPanel.spriteTriggerPlaceholder').click(), + ); + await act(async () => menuOptionByText('A').click()); + + // 다중 모드라 메뉴는 열린 채 체크 상태만 갱신된다 + expect(openListbox()).not.toBeNull(); + expect(menuOptionByText('A').getAttribute('aria-selected')).toBe('true'); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const [mode, spriteId, rawPatch, gestureId] = + mocks.patchPosition.mock.calls[0]; + const patch = rawPatch as Partial; + expect(mode).toBe('4key'); + expect(spriteId).toBe(SPRITE_ID); expect(gestureId).toBeUndefined(); - expect(record['4key']).toHaveLength(2); - expect(record['4key'][0].poses).toEqual([ + // 대상 스프라이트의 바뀐 필드만 실린다 + expect(Object.keys(patch)).toEqual(['poses']); + expect(patch.poses).toEqual([ { poseId: expect.any(String), + // 표준 관례: 자동 번호는 생성 시점에 이름으로 고정된다 (sticky) + name: 'propertiesPanel.spritePose 1', triggers: [KEY_ID_A], matchMode: 'exact', transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, }, ]); - // 같은 모드의 다른 스프라이트는 그대로 유지 - expect(record['4key'][1]).toEqual( - useSpriteStore.getState().positions['4key'][1], - ); }); - it('중복 트리거 집합은 커밋을 막고 경고를 표시한다', () => { + it('중복 트리거 집합은 커밋을 막고 경고를 표시한다', async () => { const position = spritePosition({ poses: [ { @@ -181,21 +330,93 @@ describe('SingleSpritePanel 자세 편집', () => { seed(position); render(position); + // 추가된 두 번째 자세의 팝업이 바로 열린다 act(() => buttonByText('propertiesPanel.spriteAddPose').click()); - // 두 번째 자세의 A 칩 (첫 자세 칩 다음) - const chips = [ - ...container.querySelectorAll('button[title="A"]'), - ]; - expect(chips).toHaveLength(2); - act(() => chips[1].click()); - - expect(mocks.updatePositions).not.toHaveBeenCalled(); - expect(container.textContent).toContain( + await act(async () => + triggerDropdownByText('propertiesPanel.spriteTriggerPlaceholder').click(), + ); + await act(async () => menuOptionByText('A').click()); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + // 배너 대신 드롭다운 트리거가 짧은 라벨·위험 톤·사유 툴팁을 단다 + const trigger = triggerDropdownByText( + 'propertiesPanel.spriteDuplicateShort', + ); + expect(trigger.getAttribute('title')).toBe( 'propertiesPanel.spriteDuplicateTriggers', ); + expect(trigger.querySelector('span')?.className).toContain( + 'text-danger-fg', + ); + }); + + it('키 미지정 자세가 둘이어도 중복 표시 없이 커밋만 보류한다', () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect( + container.querySelector( + 'button[title="propertiesPanel.spriteDuplicateTriggers"]', + ), + ).toBeNull(); + }); + + it('기본 상태 편집 UI는 존재하지 않는다', () => { + const position = spritePosition(); + seed(position); + render(position); + + expect( + container.querySelector( + 'button[aria-label="propertiesPanel.spriteIdlePose"]', + ), + ).toBeNull(); + expect(container.textContent).not.toContain( + 'propertiesPanel.spriteIdlePose', + ); + }); + + it('행 재클릭은 팝업을 토글한다', () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + expect(posePopup()!.getAttribute('aria-label')).toBe( + 'propertiesPanel.spritePose 1', + ); + expect(posePopup()!.dataset.fallbackHeight).toBe('220'); + + // 같은 행 재클릭 - 토글 닫힘 + act(() => poseEditButtons()[0].click()); + expect(posePopup()).toBeNull(); }); - it('자세 삭제는 남은 자세만으로 커밋한다', () => { + it('행 전환은 프리뷰를 회수 없이 교체하고 편집 세션을 새로 연다', () => { const position = spritePosition({ poses: [ { @@ -217,31 +438,336 @@ describe('SingleSpritePanel 자세 편집', () => { seed(position); render(position); - const removeButtons = [ - ...container.querySelectorAll( - 'button[title="propertiesPanel.spriteRemovePose"]', + act(() => poseEditButtons()[0].click()); + expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ + kind: 'pose', + poseId: 'pose-1', + }); + const firstPopup = posePopup(); + + // 전환 중 프리뷰가 null을 거치면 캔버스가 기본 상태로 한 번 튄다 + const transitions: Array = []; + const unsubscribe = useSpriteEditPreviewStore.subscribe((state) => { + transitions.push( + state.preview?.kind === 'pose' ? state.preview.poseId : null, + ); + }); + act(() => poseEditButtons()[1].click()); + unsubscribe(); + + expect(transitions).toEqual(['pose-2']); + expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ + kind: 'pose', + poseId: 'pose-2', + }); + // 대상별 새 세션 - 팝업이 리마운트되어 입력 draft·포커스가 남지 않는다 + expect(posePopup()!.getAttribute('aria-label')).toBe( + 'propertiesPanel.spritePose 2', + ); + expect(posePopup()).not.toBe(firstPopup); + }); + + it('자세 삭제는 남은 자세만으로 커밋한다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + { + poseId: 'pose-2', + triggers: [KEY_ID_B], + matchMode: 'exact', + transform: { x: 4, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + // 삭제는 행 ⋮ 메뉴에서 (팝업에는 삭제 버튼이 없다). + // 메뉴 항목은 after-paint로 마운트되므로 프레임을 흘려보낸다 + openPoseRowMenu(poseEditButtons()[0]); + await flushPreview(); + act(() => menuItemByText('propertiesPanel.delete').click()); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + expect(patch.poses?.map((pose) => pose.poseId)).toEqual(['pose-2']); + }); + + it('행 메뉴의 복제는 트리거만 비운 사본을 원본 아래에 추가하고 편집 팝업을 연다', async () => { + const KEY_ID_C = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A', 'S', 'D'] }, + canonicalPositions: { + '4key': [{ id: KEY_ID_A }, { id: KEY_ID_B }, { id: KEY_ID_C }], + } as never, + }); + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + name: '왼손', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: 'override.png', + }, + { + poseId: 'pose-2', + triggers: [KEY_ID_B], + matchMode: 'exact', + transform: { x: 4, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + const sourceRow = [ + ...container.querySelectorAll('[role="button"]'), + ].find((row) => row.textContent?.startsWith('왼손'))!; + openPoseRowMenu(sourceRow); + await flushPreview(); + act(() => menuItemByText('contextMenu.duplicate').click()); + + // 빈 트리거 draft라 커밋은 보류되고, 사본은 복제 접미사 이름으로 뜬다 + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect( + [...container.querySelectorAll('[role="button"]')].filter( + (row) => row.textContent?.startsWith('왼손'), ), - ]; - expect(removeButtons).toHaveLength(2); - act(() => removeButtons[0].click()); - - expect(mocks.updatePositions).toHaveBeenCalledTimes(1); - const record = mocks.updatePositions.mock.calls[0][0] as Record< - string, - CanonicalReactiveSpritePosition[] - >; - expect(record['4key'][0].poses.map((pose) => pose.poseId)).toEqual([ - 'pose-2', + ).toHaveLength(2); + expect(posePopup()!.getAttribute('aria-label')).toBe( + '왼손 common.copySuffix', + ); + // 이름 있는 사본이 사이에 끼어도 무명 상태의 번호는 밀리지 않는다 + expect(poseEditButtons()[0].textContent).toContain( + 'propertiesPanel.spritePose 1', + ); + + // 담당 키를 지정하면 사본까지 실려 커밋된다 + await act(async () => + triggerDropdownByText('propertiesPanel.spriteTriggerPlaceholder').click(), + ); + await act(async () => menuOptionByText('D').click()); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + expect(patch.poses).toEqual([ + position.poses[0], + { + poseId: expect.any(String), + name: '왼손 common.copySuffix', + triggers: [KEY_ID_C], + matchMode: 'exact', + transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: 'override.png', + }, + // 구조 변경 시 무명 상태는 보이던 번호가 이름으로 고정된다 + { ...position.poses[1], name: 'propertiesPanel.spritePose 1' }, ]); + expect(patch.poses?.[1].poseId).not.toBe('pose-1'); }); - it('죽은 트리거 참조는 표시되고 클릭으로 제거된다', () => { - const deadId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + it('사본을 다시 복제하면 접미사를 겹치지 않고 숫자를 올린다', async () => { const position = spritePosition({ poses: [ { poseId: 'pose-1', - triggers: [KEY_ID_A, deadId], + name: '왼손 common.copySuffix', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + const sourceRow = [ + ...container.querySelectorAll('[role="button"]'), + ].find((row) => row.textContent?.startsWith('왼손'))!; + openPoseRowMenu(sourceRow); + await flushPreview(); + act(() => menuItemByText('contextMenu.duplicate').click()); + + expect(posePopup()!.getAttribute('aria-label')).toBe( + '왼손 common.copySuffix 2', + ); + }); + + it('숫자 카운터 사본은 루트로 복귀하고, 생성 불가 숫자는 사용자 작명으로 보존한다', async () => { + const makeNamedPose = (poseId: string, name: string, key: string) => ({ + poseId, + name, + triggers: [key], + matchMode: 'exact' as const, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }); + const position = spritePosition({ + poses: [ + // 생성 규칙상 카운터 - 루트 '왼손'으로 축약돼 빈 자리부터 다시 센다 + makeNamedPose('pose-1', '왼손 common.copySuffix 3', KEY_ID_A), + // 카운터로 생성될 수 없는 0 - 이름 전체가 루트로 보존된다 + makeNamedPose('pose-2', '왼손 common.copySuffix 0', KEY_ID_B), + ], + }); + seed(position); + render(position); + + const rowByName = (name: string) => + [...container.querySelectorAll('[role="button"]')].find( + (row) => row.textContent?.startsWith(name), + )!; + + openPoseRowMenu(rowByName('왼손 common.copySuffix 3')); + await flushPreview(); + act(() => menuItemByText('contextMenu.duplicate').click()); + expect(posePopup()!.getAttribute('aria-label')).toBe( + '왼손 common.copySuffix', + ); + + openPoseRowMenu(rowByName('왼손 common.copySuffix 0')); + await flushPreview(); + act(() => menuItemByText('contextMenu.duplicate').click()); + expect(posePopup()!.getAttribute('aria-label')).toBe( + '왼손 common.copySuffix 0 common.copySuffix', + ); + }); + + it('상태를 삭제해도 남은 상태의 번호는 유지된다', async () => { + const poses = Array.from({ length: 5 }, (_, index) => ({ + poseId: `pose-${index + 1}`, + triggers: [`key-${index}`], + matchMode: 'exact' as const, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + })); + const position = spritePosition({ poses }); + seed(position); + render(position); + + // 상태 4 삭제 + openPoseRowMenu(poseEditButtons()[3]); + await flushPreview(); + act(() => menuItemByText('propertiesPanel.delete').click()); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + // 상태 5는 상태 4로 당겨지지 않고 번호를 유지한다 (sticky) + expect(patch.poses?.map((pose) => pose.name)).toEqual([ + 'propertiesPanel.spritePose 1', + 'propertiesPanel.spritePose 2', + 'propertiesPanel.spritePose 3', + 'propertiesPanel.spritePose 5', + ]); + const rowTexts = [ + ...container.querySelectorAll('[role="button"]'), + ] + .map((row) => row.textContent ?? '') + .filter((text) => text.startsWith('propertiesPanel.spritePose ')); + expect(rowTexts.some((text) => text.includes('spritePose 5'))).toBe(true); + expect(rowTexts.some((text) => text.includes('spritePose 4'))).toBe(false); + + // 새 상태는 비어 있는 가장 작은 번호(4)를 재사용한다 + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + expect(posePopup()!.getAttribute('aria-label')).toBe( + 'propertiesPanel.spritePose 4', + ); + }); + + it('저장된 자동 이름은 무변경 이름 변경에서 커밋되지 않는다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + name: 'propertiesPanel.spritePose 1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + openPoseRowMenu(poseEditButtons()[0]); + await flushPreview(); + act(() => menuItemByText('contextMenu.rename').click()); + + const input = + container.querySelector('input.caret-accent')!; + expect(input.value).toBe('propertiesPanel.spritePose 1'); + act(() => input.blur()); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('이름을 비우면 점유 번호를 피해 자동 이름을 재부여한다', async () => { + const makeAutoNamed = (poseId: string, ordinal: number, key: string) => ({ + poseId, + name: `propertiesPanel.spritePose ${ordinal}`, + triggers: [key], + matchMode: 'exact' as const, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }); + const position = spritePosition({ + poses: [ + makeAutoNamed('pose-1', 1, KEY_ID_A), + makeAutoNamed('pose-2', 5, KEY_ID_B), + ], + }); + seed(position); + render(position); + + openPoseRowMenu(poseEditButtons()[1]); + await flushPreview(); + act(() => menuItemByText('contextMenu.rename').click()); + + const input = + container.querySelector('input.caret-accent')!; + act(() => { + setInputValue(input, ''); + }); + act(() => input.blur()); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + expect(patch.poses?.[1]).toMatchObject({ + poseId: 'pose-2', + name: 'propertiesPanel.spritePose 2', + }); + }); + + it('명시 자동 이름과 무명이 혼재해도 표시 번호가 중복되지 않는다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + name: 'propertiesPanel.spritePose 1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + { + poseId: 'pose-2', + triggers: [KEY_ID_B], matchMode: 'exact', transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, @@ -251,15 +777,568 @@ describe('SingleSpritePanel 자세 편집', () => { seed(position); render(position); - const deadChip = buttonByText('propertiesPanel.spriteMissingKey'); - expect(deadChip).toBeTruthy(); - act(() => deadChip.click()); + // 무명 행은 점유된 1을 건너뛰고 2로 표시된다 + const rows = poseEditButtons(); + expect(rows[1].textContent).toContain('propertiesPanel.spritePose 2'); + + // 구조 변경에서도 같은 번호로 고정된다 + openPoseRowMenu(rows[1]); + await flushPreview(); + act(() => menuItemByText('contextMenu.duplicate').click()); + expect(posePopup()!.getAttribute('aria-label')).toBe( + 'propertiesPanel.spritePose 2 common.copySuffix', + ); + }); + + it('상태가 최대 개수면 복제 메뉴 항목이 비활성이다', async () => { + const poses = Array.from({ length: 64 }, (_, index) => ({ + poseId: `pose-${index}`, + triggers: [`key-${index}`], + matchMode: 'exact' as const, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + })); + const position = spritePosition({ poses }); + seed(position); + render(position); + + openPoseRowMenu(poseEditButtons()[0]); + await flushPreview(); + const item = menuItemByText('contextMenu.duplicate'); + expect(item.disabled).toBe(true); + + act(() => item.click()); + expect(poseEditButtons()).toHaveLength(64); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('복제 draft는 이전 커밋의 늦은 ack 재수화에도 지워지지 않는다', async () => { + const basePose = { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact' as const, + transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }; + const position = spritePosition({ poses: [basePose] }); + seed(position); + render(position); + + openPoseRowMenu(poseEditButtons()[0]); + await flushPreview(); + act(() => menuItemByText('contextMenu.duplicate').click()); + expect(poseEditButtons()).toHaveLength(2); + + // 사본 이전 내용 그대로의 canonical 스냅샷 도착 - draft와 다르므로 유지돼야 한다 + const ack = spritePosition({ + poses: [{ ...basePose, transform: { ...basePose.transform } }], + }); + seed(ack); + render(ack); + expect(poseEditButtons()).toHaveLength(2); + }); + + it('행 메뉴의 이름 변경은 인라인 입력으로 name을 커밋한다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + openPoseRowMenu(poseEditButtons()[0]); + await flushPreview(); + act(() => menuItemByText('contextMenu.rename').click()); + + const input = + container.querySelector('input.caret-accent')!; + expect(input).toBeTruthy(); + act(() => { + setInputValue(input, '왼팔'); + }); + act(() => input.blur()); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + expect(patch.poses?.[0]).toMatchObject({ poseId: 'pose-1', name: '왼팔' }); + // 행 표시가 이름으로 바뀐다 (draft 반영) + expect(container.textContent).toContain('왼팔'); + }); + + it('이름 변경 입력은 표시 중인 기본 이름으로 시작하고 포커스를 가진다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + openPoseRowMenu(poseEditButtons()[0]); + await flushPreview(); + act(() => menuItemByText('contextMenu.rename').click()); + + const input = + container.querySelector('input.caret-accent')!; + expect(input.value).toBe('propertiesPanel.spritePose 1'); + expect(document.activeElement).toBe(input); + + // 기본 표시명 그대로 확정하면 명시 이름을 저장하지 않는다 + act(() => input.blur()); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('이름 변경 Escape는 커밋 없이 닫힌다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + openPoseRowMenu(poseEditButtons()[0]); + await flushPreview(); + act(() => menuItemByText('contextMenu.rename').click()); + + const input = + container.querySelector('input.caret-accent')!; + act(() => { + setInputValue(input, '버릴 이름'); + }); + act(() => { + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + }); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect(container.querySelector('input.caret-accent')).toBeNull(); + }); + + it('선택된 키를 다시 클릭하면 해제로 커밋한다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A, KEY_ID_B], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + await act(async () => triggerDropdownByText('A, S').click()); + await act(async () => menuOptionByText('S').click()); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + expect(patch.poses?.[0].triggers).toEqual([KEY_ID_A]); + expect(openListbox()).not.toBeNull(); + expect(menuOptionByText('S').getAttribute('aria-selected')).toBe('false'); + }); + + it('죽은 트리거 참조는 드롭다운 항목으로 표시되고 토글로 제거된다', async () => { + const deadId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A, deadId], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + await act(async () => + triggerDropdownByText('propertiesPanel.spriteMissingKey').click(), + ); + const deadOption = menuOptionByText('propertiesPanel.spriteMissingKey'); + expect(deadOption).toBeTruthy(); + expect(deadOption.className).toContain('text-danger-fg'); + await act(async () => deadOption.click()); + + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + expect(patch.poses?.[0].triggers).toEqual([KEY_ID_A]); + }); + + it('담당 키 드롭다운은 팝업 크롬과 같은 기본 크기 전폭으로 렌더된다', () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + // 팝업이 닫혀 있으면 패널에 담당 키 드롭다운이 없다 (요약 행뿐) + expect(triggerDropdownByText('A')).toBeUndefined(); + + act(() => poseEditButtons()[0].click()); + const trigger = triggerDropdownByText('A'); + expect(trigger.className).toContain('h-[23px]'); + expect(trigger.className).toContain('w-full'); + }); + + it('이미지 설정 팝업의 미리보기로 선택과 초기화를 커밋한다', async () => { + const position = spritePosition({ baseImage: 'hand.png' }); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.configure').click()); + const popup = popupByLabel('propertiesPanel.spriteBaseImage'); + expect(popup).toBeTruthy(); + + // 초기화 칩은 baseImage null 커밋 + const reset = popup!.querySelector( + 'button[aria-label="imagePicker.reset"]', + )!; + act(() => reset.click()); + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + expect(mocks.patchPosition.mock.calls[0][2]).toEqual({ baseImage: null }); + + // 미리보기 전면 선택 버튼은 파일창 결과를 baseImage로 커밋 + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'body.png', + }); + const select = popup!.querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )!; + await act(async () => select.click()); + expect(mocks.patchPosition).toHaveBeenCalledTimes(2); + expect(mocks.patchPosition.mock.calls[1][2]).toEqual({ + baseImage: 'body.png', + }); + }); + + it('이미지 설정 팝업의 위치·기준점 입력은 imageRect·pivot 패치로 커밋한다', () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.configure').click()); + const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; + // 표시 드롭다운도 팝업으로 이동했다 + expect(popup.querySelector('button[aria-haspopup="listbox"]')).toBeTruthy(); + + const xInput = popup.querySelector( + 'input[aria-label="propertiesPanel.spriteImageRect X"]', + )!; + act(() => { + xInput.focus(); + setInputValue(xInput, '12'); + }); + act(() => xInput.blur()); + expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ + imageRect: { x: 12, y: 0, width: 100, height: 100 }, + }); + + // 기준점은 팝업이 아니라 패널의 기본 이미지 행 아래에 산다 + const pivotY = container.querySelector( + 'input[aria-label="propertiesPanel.spritePivot Y"]', + )!; + act(() => { + pivotY.focus(); + setInputValue(pivotY, '25'); + }); + act(() => pivotY.blur()); + expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ + pivot: { x: 0.5, y: 0.25 }, + }); + }); + + it('이미지 팝업과 자세 팝업은 배타 전환되고 자세 프리뷰는 발행·회수된다', () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.configure').click()); + expect(popupByLabel('propertiesPanel.spriteBaseImage')).toBeTruthy(); + // 이미지 설정 팝업은 캔버스 보조 표시가 없다 + expect(useSpriteEditPreviewStore.getState().preview).toBeNull(); + + // 자세 행 클릭 - 이미지 팝업이 닫히고 자세 팝업으로 전환, 프리뷰 발행 + act(() => poseEditButtons()[0].click()); + expect(popupByLabel('propertiesPanel.spriteBaseImage')).toBeNull(); + expect(popupByLabel('propertiesPanel.spritePose 1')).toBeTruthy(); + expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + }); + + // 이미지 팝업으로 되돌아가면 자세 프리뷰는 회수 + act(() => buttonByText('propertiesPanel.configure').click()); + expect(useSpriteEditPreviewStore.getState().preview).toBeNull(); + expect(popupByLabel('propertiesPanel.spriteBaseImage')).toBeTruthy(); + + // 같은 버튼 재클릭 토글로 닫기 + act(() => buttonByText('propertiesPanel.configure').click()); + expect(posePopup()).toBeNull(); + }); + + it('canonical 착지 전 draft 자세도 프리뷰 스냅샷으로 발행된다', () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + + const preview = useSpriteEditPreviewStore.getState().preview; + expect(preview).toMatchObject({ + kind: 'pose', + positionId: SPRITE_ID, + preferFallback: true, + }); + expect( + preview?.kind === 'pose' ? preview.fallbackPose.triggers : null, + ).toEqual([]); + }); + + it('기존 자세를 무효 draft로 만들면 프리뷰가 스냅샷 우선으로 전환된다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ + poseId: 'pose-1', + preferFallback: false, + }); + + // 유일한 담당 키 해제 - 커밋이 막힌 빈 트리거 draft가 된다 + await act(async () => triggerDropdownByText('A').click()); + await act(async () => menuOptionByText('A').click()); + + const preview = useSpriteEditPreviewStore.getState().preview; + expect(preview).toMatchObject({ kind: 'pose', preferFallback: true }); + expect( + preview?.kind === 'pose' ? preview.fallbackPose.triggers : null, + ).toEqual([]); + }); + + it('기준점 행을 만지는 동안 축 마커가 발행되고 벗어나면 회수된다', () => { + const position = spritePosition(); + seed(position); + render(position); + + const pivotX = container.querySelector( + 'input[aria-label="propertiesPanel.spritePivot X"]', + )!; + act(() => pivotX.focus()); + expect(useSpriteEditPreviewStore.getState().preview).toEqual({ + kind: 'pivot', + positionId: SPRITE_ID, + }); + + act(() => pivotX.blur()); + expect(useSpriteEditPreviewStore.getState().preview).toBeNull(); + }); + + it('스프라이트 전환 시 자세 프리뷰가 회수된다', () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + expect(useSpriteEditPreviewStore.getState().preview).not.toBeNull(); + + render(spritePosition({ id: OTHER_SPRITE_ID })); + expect(useSpriteEditPreviewStore.getState().preview).toBeNull(); + }); + + it('파일창 대기 중 언마운트되면 결과를 폐기한다', async () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.configure').click()); + let resolveLoad!: (value: { success: boolean; imagePath?: string }) => void; + mocks.imageLoad.mockReturnValue( + new Promise((resolve) => { + resolveLoad = resolve; + }) as never, + ); + const select = popupByLabel( + 'propertiesPanel.spriteBaseImage', + )!.querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )!; + act(() => select.click()); + act(() => root.unmount()); + // afterEach 정리가 빈 루트를 다시 언마운트하게 교체 + root = createRoot(container); + + await act(async () => { + resolveLoad({ success: true, imagePath: 'late.png' }); + }); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('이미지 설정 입력은 preview로 흐르고 Escape는 게스처를 취소한다', async () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.configure').click()); + const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; + const xInput = popup.querySelector( + 'input[aria-label="propertiesPanel.spriteImageRect X"]', + )!; + act(() => { + xInput.focus(); + setInputValue(xInput, '12'); + }); + await flushPreview(); + + const previewPatches = mocks.preview.mock.calls.map( + (call) => (call[1] as Array<{ patch: Record }>)[0].patch, + ); + expect(previewPatches.at(-1)).toEqual({ + imageRect: { x: 12, y: 0, width: 100, height: 100 }, + }); + + act(() => { + xInput.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + }); + expect(mocks.gestureCancel).toHaveBeenCalled(); + }); + + it('크기 입력은 1 미만을 막고 표시 변경은 imageFit으로 커밋한다', async () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.configure').click()); + const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; + const wInput = popup.querySelector( + 'input[aria-label="propertiesPanel.spriteImageSize W"]', + )!; + act(() => { + wInput.focus(); + setInputValue(wInput, '0'); + }); + act(() => wInput.blur()); + expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ + imageRect: { x: 0, y: 0, width: 1, height: 100 }, + }); + + await act(async () => + popup + .querySelector('button[aria-haspopup="listbox"]')! + .click(), + ); + await act(async () => + menuOptionByText('propertiesPanel.imageFitFill').click(), + ); + expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ + imageFit: 'fill', + }); + }); + + it('자세 이미지 선택은 파일창 결과를 자세에 커밋한다', async () => { + const position = spritePosition({ + poses: [ + { + poseId: 'pose-1', + triggers: [KEY_ID_A], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'pose.png', + }); + + const pickButton = [ + ...posePopup()!.querySelectorAll('button'), + ].find( + (button) => button.textContent === 'propertiesPanel.spriteImageSelect', + )!; + await act(async () => pickButton.click()); - expect(mocks.updatePositions).toHaveBeenCalledTimes(1); - const record = mocks.updatePositions.mock.calls[0][0] as Record< - string, - CanonicalReactiveSpritePosition[] - >; - expect(record['4key'][0].poses[0].triggers).toEqual([KEY_ID_A]); + expect(mocks.canDecodeImage).toHaveBeenCalledWith('pose.png'); + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock + .calls[0][2] as Partial; + expect(patch.poses?.[0].imageOverride).toBe('pose.png'); }); }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index aafc7842d..bb5fb0de1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { editGestureController } from '@src/renderer/editor/runtime/editGestureController'; import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; @@ -7,16 +7,17 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { spriteItemsApi } from '@api/modules/itemsApi'; import { imageApi } from '@api/modules/resourceApi'; import { canDecodeImage } from '@utils/core/assetProbe'; +import { isHTMLElementNode } from '@utils/dom/isElementNode'; import { slotDisplayName } from '@utils/keySlot'; -import { AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; +import { ACTION_BUTTON_CLASS, AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; +import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; import { DEFAULT_SPRITE_TRANSITION_EASING, + IDENTITY_SPRITE_TRANSFORM, SPRITE_CONSTRAINTS, findDuplicateTriggerPose, type ReactiveSpritePosition, - type SpriteImageFit, type SpritePose, - type SpriteTransform, } from '@src/types/key/sprites'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; @@ -24,16 +25,24 @@ import { PropertyRow, PropertySection, NumberInput, - CloseIcon, TABS, TabType, } from '../index'; import Dropdown from '@components/main/common/Dropdown'; +import ListAddRow from '@components/main/common/ListAddRow'; +import PopupExit from '@components/main/Modal/PopupExit'; +import ListPopup, { type ListItem } from '@components/main/Modal/ListPopup'; +import { + pickerRowClass, + pickerMoreButtonClass, + pickerMoreButtonVisibleClass, + pickerMoreButtonHiddenClass, +} from '@components/main/Modal/content/pickers/pickerRowClass'; +import MoreVerticalIcon from '@components/main/Modal/content/pickers/MoreVerticalIcon'; +import { usePickerItemMenu } from '@hooks/usePickerItemMenu'; import EditSessionBoundary from '../EditSessionBoundary'; - -// 공용 버튼 크롬 (knob 패널의 설정 버튼과 동일) -const ACTION_BUTTON_CLASS = - 'px-[8px] h-[23px] bg-fill hover:bg-fill-hover active:bg-fill-active transition-colors duration-fast rounded-md flex items-center justify-center text-fg text-body'; +import SpritePoseEditorPopup from './SpritePoseEditorPopup'; +import SpriteImageSettingsPopup from './SpriteImageSettingsPopup'; // 24 그리드를 12px로 렌더 - 스트로크 2.4가 화면상 1.2 const RenameIcon: React.FC = () => ( @@ -74,115 +83,22 @@ const SPRITE_EASING_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ { label: 'Overshoot', value: 'cubic-bezier(0.34, 1.56, 0.64, 1)' }, ]; -interface SpriteTransformFieldsProps { - transform: SpriteTransform; - onCommit: (next: SpriteTransform) => void; - onPreview?: (next: SpriteTransform) => void; - t: (key: string) => string; -} +// 편집 팝업 대상 - 두 변형 모두 소유 스프라이트에 결합한다 +// (대상 초기화 effect 전 한 렌더 동안 다음 스프라이트 재사용 방지) +type SpriteEditorTarget = + | { kind: 'image'; positionId: string } + | { kind: 'pose'; positionId: string; poseId: string }; -// 대기 자세와 각 자세 행이 공유하는 변환 4필드 -const SpriteTransformFields: React.FC = ({ - transform, - onCommit, - onPreview, - t, -}) => { - const { offset, rotation, scale } = SPRITE_CONSTRAINTS; - const commitField = (patch: Partial) => - onCommit({ ...transform, ...patch }); - const previewField = onPreview - ? (patch: Partial) => onPreview({ ...transform, ...patch }) - : undefined; - return ( - <> - - - commitField({ x: clamp(value, offset.min, offset.max) }) - } - onPreview={ - previewField - ? (value) => - previewField({ x: clamp(value, offset.min, offset.max) }) - : undefined - } - onCancel={() => editGestureController.cancel()} - prefix="X" - width={AXIS_FIELD_WIDTH} - min={offset.min} - max={offset.max} - allowDecimal - decimalScale={1} - /> - - commitField({ y: clamp(value, offset.min, offset.max) }) - } - onPreview={ - previewField - ? (value) => - previewField({ y: clamp(value, offset.min, offset.max) }) - : undefined - } - onCancel={() => editGestureController.cancel()} - prefix="Y" - width={AXIS_FIELD_WIDTH} - min={offset.min} - max={offset.max} - allowDecimal - decimalScale={1} - /> - - - - commitField({ rotation: clamp(value, rotation.min, rotation.max) }) - } - onPreview={ - previewField - ? (value) => - previewField({ - rotation: clamp(value, rotation.min, rotation.max), - }) - : undefined - } - onCancel={() => editGestureController.cancel()} - suffix="°" - width={AXIS_FIELD_WIDTH} - min={rotation.min} - max={rotation.max} - allowDecimal - decimalScale={1} - /> - - - - commitField({ scale: clamp(value, scale.min, scale.max) }) - } - onPreview={ - previewField - ? (value) => - previewField({ scale: clamp(value, scale.min, scale.max) }) - : undefined - } - onCancel={() => editGestureController.cancel()} - suffix="×" - width={AXIS_FIELD_WIDTH} - min={scale.min} - max={scale.max} - allowDecimal - decimalScale={2} - step={0.1} - /> - - - ); +const isSameEditorTarget = ( + current: SpriteEditorTarget | null, + next: SpriteEditorTarget, +): boolean => { + if (current === null) return false; + if (current.kind !== next.kind) return false; + if (current.positionId !== next.positionId) return false; + return current.kind === 'pose' + ? next.kind === 'pose' && current.poseId === next.poseId + : true; }; interface SingleSpritePanelProps { @@ -223,6 +139,26 @@ export const SingleSpritePanel: React.FC = ({ ); const loadingImageRef = useRef(false); + const panelRef = useRef(null); + const setRef = (node: HTMLDivElement | null) => { + panelRef.current = node; + setPanelElement(node); + }; + + // 편집 팝업 앵커 - 여는 순간의 행·버튼(또는 웰)을 담는다 + const poseAnchorRef = useRef(null); + // 상태 목록 영역 - 여기서의 pointerdown은 팝업 바깥닫힘을 거치지 않고 + // 행 클릭이 대상을 직접 교체한다 (닫힘-재열림 사이 idle 프리뷰 깜빡임 방지) + const poseListRef = useRef(null); + const [editorTarget, setEditorTarget] = useState( + null, + ); + + // 자세 팝업은 대상이 갈리면 닫는다 + useEffect(() => { + setEditorTarget(null); + }, [selectedKeyType, position.id]); + // 무효 자세(빈/중복 트리거)는 백엔드가 거부하므로 커밋 착지 전까지 패널이 값을 들고 있는다 const [posesDraft, setPosesDraft] = useState<{ id: string; @@ -256,8 +192,6 @@ export const SingleSpritePanel: React.FC = ({ return [{ id: keyPosition.id, label }]; }, ); - const keyOptionIds = new Set(keyOptions.map((option) => option.id)); - const displayPoses = posesDraft && posesDraft.id === position.id ? posesDraft.poses @@ -268,24 +202,103 @@ export const SingleSpritePanel: React.FC = ({ ); const posesCommittable = duplicatePose === null && !hasEmptyTriggerPose; - // 전체 필드 패치 커밋: 다음 spritePositions 레코드를 구성해 editor_commit으로. + // 대상 초기화 effect가 돌기 전 한 렌더 동안 다음 스프라이트에 팝업이 + // 재사용되지 않게 소유자 일치를 렌더 단계에서 판정한다 + const activeEditorTarget = + editorTarget && editorTarget.positionId === position.id + ? editorTarget + : null; + const editingPoseIndex = + activeEditorTarget?.kind === 'pose' + ? displayPoses.findIndex( + (pose) => pose.poseId === activeEditorTarget.poseId, + ) + : -1; + const editingPose = + editingPoseIndex >= 0 ? displayPoses[editingPoseIndex] : null; + const imageEditing = activeEditorTarget?.kind === 'image'; + + // 기준점 행을 만지는 동안 캔버스에 축 마커를 띄운다 (포커스 또는 접두 스크럽) + const pivotRowRef = useRef(null); + const [pivotEngaged, setPivotEngaged] = useState(false); + useEffect(() => { + if (!pivotEngaged) return; + // 접두 스크럽은 포커스를 옮기지 않는다 - 포인터를 뗀 뒤 포커스가 행 밖이면 해제 + const ownerWindow = + pivotRowRef.current?.ownerDocument.defaultView ?? window; + const handleUp = () => { + const row = pivotRowRef.current; + if (row && row.contains(row.ownerDocument.activeElement)) return; + setPivotEngaged(false); + }; + ownerWindow.addEventListener('pointerup', handleUp); + return () => ownerWindow.removeEventListener('pointerup', handleUp); + }, [pivotEngaged]); + + // 편집 중인 것을 캔버스 보조 표시로 발행한다 (편집창 전용). + // 자세 팝업은 그 자세의 렌더(draft도 스냅샷으로), 기준점 편집은 축 마커 + useLayoutEffect(() => { + const store = useSpriteEditPreviewStore.getState(); + if (activeEditorTarget?.kind === 'pose' && editingPose) { + store.publish({ + kind: 'pose', + positionId: activeEditorTarget.positionId, + poseId: editingPose.poseId, + fallbackPose: editingPose, + // 무효 draft는 canonical에 착지하지 못하므로 캔버스가 스냅샷을 우선한다 + preferFallback: !posesCommittable, + }); + } else if (pivotEngaged) { + store.publish({ kind: 'pivot', positionId: position.id }); + } else { + store.clear(); + } + }, [ + activeEditorTarget, + editingPose, + posesCommittable, + pivotEngaged, + position.id, + ]); + // 언마운트 잔류 방지 - layout 시점에 회수해 한 프레임 잔상도 남기지 않는다 + useLayoutEffect(() => () => useSpriteEditPreviewStore.getState().clear(), []); + + // 비동기 이미지 선택 완료 시점의 유효성 - 대상 전환·언마운트가 지나면 세대가 + // 갈려 이전 요청을 폐기한다. 자세 결합은 최신 poses에서 poseId로 다시 찾는다 + const asyncGenerationRef = useRef(0); + useLayoutEffect(() => { + const generation = asyncGenerationRef; + return () => { + generation.current += 1; + }; + }, [position.id, selectedKeyType]); + const latestPosesRef = useRef(displayPoses); + useEffect(() => { + latestPosesRef.current = displayPoses; + }); + + // id 기반 필드 패치 커밋: 직렬 슬롯 안 최신 base로 patch를 생성해 + // 낙관 로컬 적용이 즉시 canonical에 반영된다. // 활성 preview 게스처가 있으면 gestureId를 실어 정산한다 const commitFields = (patch: Partial) => { - const canonical = useSpriteStore.getState().positions; - const modePositions = canonical[selectedKeyType] ?? []; - const index = modePositions.findIndex( - (sprite) => sprite.id === position.id, - ); - if (index < 0) return; - const nextMode = [...modePositions]; - nextMode[index] = { ...nextMode[index], ...patch }; - const next = { ...canonical, [selectedKeyType]: nextMode }; const gestureId = editGestureController.activeGestureId() ?? undefined; - const persisted = spriteItemsApi.updatePositions(next, gestureId); + const persisted = spriteItemsApi.patchPosition( + selectedKeyType, + position.id, + patch, + gestureId, + ); editGestureController.settleCommit(persisted); - void persisted.catch((error) => { - console.error('Failed to update sprite', error); - }); + void persisted + .then((result) => { + // 무커밋 조용 종료 방지: 대상 소실은 흔적을 남긴다 + if (result === 'targetMissing') { + console.error('Sprite patch target missing', position.id); + } + }) + .catch((error) => { + console.error('Failed to update sprite', error); + }); }; // 스크럽·타이핑 실시간 프리뷰 (spritePosition 도메인) @@ -327,20 +340,244 @@ export const SingleSpritePanel: React.FC = ({ replacePose(poseIndex, { triggers: nextTriggers }); }; - const addPose = () => { + const openEditorPopup = (target: SpriteEditorTarget, anchor: HTMLElement) => { + if (isSameEditorTarget(editorTarget, target)) { + setEditorTarget(null); + return; + } + poseAnchorRef.current = anchor; + setEditorTarget(target); + }; + + const addPose = (anchor: HTMLElement) => { if (displayPoses.length >= SPRITE_CONSTRAINTS.maxPoses) return; + const materialized = materializePoseNames(displayPoses); const pose: SpritePose = { poseId: crypto.randomUUID(), + name: nextDefaultPoseName(materialized), triggers: [], matchMode: 'exact', - transform: { ...position.idleTransform }, + // 새 상태는 항등에서 출발 - 기본 배치와의 차이만 상태가 가진다 + transform: { ...IDENTITY_SPRITE_TRANSFORM }, imageOverride: null, }; - updatePoses([...displayPoses, pose]); + updatePoses([...materialized, pose]); + // 새 상태는 담당 키 선택이 다음 단계라 편집 팝업을 바로 연다. + // 추가 행은 마지막 상태에서 같은 렌더에 사라지므로 웰 컨테이너를 앵커로 쓴다 + openEditorPopup( + { kind: 'pose', positionId: position.id, poseId: pose.poseId }, + anchor.parentElement ?? anchor, + ); }; const removePose = (poseIndex: number) => - updatePoses(displayPoses.filter((_, index) => index !== poseIndex)); + updatePoses( + materializePoseNames(displayPoses).filter( + (_, index) => index !== poseIndex, + ), + ); + + // "이름 복제"·"이름 복제 3"을 다시 복제해도 루트를 유지하고 숫자만 올린다. + // 카운터는 2부터만 생성되므로 "복제 0"·"복제 01" 같은 이름은 사용자 작명으로 보존 + const stripCopySuffix = (name: string, suffix: string): string => { + const marker = ` ${suffix}`; + const markerIndex = name.lastIndexOf(marker); + if (markerIndex <= 0) return name; + const tail = name.slice(markerIndex + marker.length); + if (tail === '') return name.slice(0, markerIndex); + const digits = tail.startsWith(' ') ? tail.slice(1) : ''; + const isGeneratedCounter = + /^\d+$/.test(digits) && + digits === String(Number(digits)) && + Number(digits) >= 2; + return isGeneratedCounter ? name.slice(0, markerIndex) : name; + }; + + // 사본 이름: 원본 이름 + 복제 접미사, 겹치면 2부터 숫자를 올린다 + const copyPoseName = (poses: SpritePose[], sourceIndex: number): string => { + const suffix = t('common.copySuffix') || '복제'; + const base = poses[sourceIndex].name || resolvedNames[sourceIndex]; + const root = stripCopySuffix(base, suffix); + const usedNames = new Set( + poses.flatMap((pose) => (pose.name ? [pose.name] : [])), + ); + let candidate = `${root} ${suffix}`; + for (let counter = 2; usedNames.has(candidate); counter += 1) { + candidate = `${root} ${suffix} ${counter}`; + } + return candidate; + }; + + const clonePose = (sourcePoseId: string) => { + if (displayPoses.length >= SPRITE_CONSTRAINTS.maxPoses) return; + const materialized = materializePoseNames(displayPoses); + const sourceIndex = materialized.findIndex( + (entry) => entry.poseId === sourcePoseId, + ); + if (sourceIndex < 0) return; + const source = materialized[sourceIndex]; + const pose: SpritePose = { + ...source, + poseId: crypto.randomUUID(), + name: copyPoseName(materialized, sourceIndex), + transform: { ...source.transform }, + // 같은 트리거 집합은 저장이 거부되므로 담당 키는 비워서 시작한다. + // 빈 트리거 draft는 상태 추가와 같은 보류 경로를 탄다 + triggers: [], + }; + updatePoses([ + ...materialized.slice(0, sourceIndex + 1), + pose, + ...materialized.slice(sourceIndex + 1), + ]); + // 사본은 원본 바로 아래 행 - 담당 키 지정이 다음 단계라 편집 팝업을 바로 연다. + // 분리 패널 창의 행은 다른 realm이라 instanceof 대신 realm-safe 판정을 쓴다 + const sourceRow = poseListRef.current?.children[sourceIndex]; + openEditorPopup( + { kind: 'pose', positionId: position.id, poseId: pose.poseId }, + isHTMLElementNode(sourceRow) + ? sourceRow + : poseListRef.current ?? panelRef.current!, + ); + }; + + // 행 ⋮ 메뉴 - 피커 행과 같은 부품 (이름 변경·복제·삭제) + const poseMenu = usePickerItemMenu(); + const poseMenuItems: ListItem[] = [ + { id: 'rename', label: t('contextMenu.rename') || '이름 변경' }, + { + id: 'duplicate', + label: t('contextMenu.duplicate') || '복제', + disabled: displayPoses.length >= SPRITE_CONSTRAINTS.maxPoses, + }, + { id: 'delete', label: t('propertiesPanel.delete') || '삭제' }, + ]; + + // 행 인라인 이름 변경 - 레이어 탭·피커 행과 같은 규약 (Escape·blur 경합은 ref로) + const [renamingPoseId, setRenamingPoseId] = useState(null); + const [poseRenameValue, setPoseRenameValue] = useState(''); + const poseRenameInputRef = useRef(null); + const poseRenameCancelledRef = useRef(false); + useEffect(() => { + if (!renamingPoseId) return; + poseRenameInputRef.current?.focus(); + poseRenameInputRef.current?.select(); + }, [renamingPoseId]); + + const poseNameLabel = t('propertiesPanel.spritePose') || '상태'; + + // 무명(name=null) 상태에 줄 번호를 표시·고정 공용으로 계산한다. + // 저장된 '상태 N'이 점유한 번호는 건너뛰므로 명시 이름과 혼재해도 중복이 없다 + const resolvedPoseNames = (poses: readonly SpritePose[]): string[] => { + const prefix = `${poseNameLabel} `; + const used = new Set(); + for (const pose of poses) { + if (!pose.name?.startsWith(prefix)) continue; + const digits = pose.name.slice(prefix.length); + if (!/^\d+$/.test(digits) || digits !== String(Number(digits))) continue; + used.add(Number(digits)); + } + let next = 1; + return poses.map((pose) => { + if (pose.name) return pose.name; + while (used.has(next)) next += 1; + used.add(next); + return `${poseNameLabel} ${next}`; + }); + }; + const resolvedNames = resolvedPoseNames(displayPoses); + + // 구조 변경(추가·복제·삭제) 직전에 무명 상태의 현재 표시 번호를 이름으로 고정한다. + // 표시값 그대로 저장하므로 화면 변화는 없고, 이후 삽입·삭제에도 번호가 유지된다 (sticky) + const materializePoseNames = (poses: SpritePose[]): SpritePose[] => { + const names = resolvedPoseNames(poses); + return poses.map((pose, index) => + pose.name ? pose : { ...pose, name: names[index] }, + ); + }; + + // 새 상태 기본 이름 - 점유된 '상태 N' 중 비어 있는 가장 작은 번호 + const nextDefaultPoseName = (poses: SpritePose[]): string => + resolvedPoseNames([ + ...poses, + { + poseId: 'next', + triggers: [], + matchMode: 'exact', + transform: IDENTITY_SPRITE_TRANSFORM, + imageOverride: null, + }, + ])[poses.length]; + + const startPoseRename = (poseId: string) => { + const poseIndex = displayPoses.findIndex( + (entry) => entry.poseId === poseId, + ); + if (poseIndex < 0) return; + poseRenameCancelledRef.current = false; + // 패널 헤더처럼 표시 중인 이름으로 시작해 전체 선택한다 + setPoseRenameValue( + displayPoses[poseIndex].name || resolvedNames[poseIndex], + ); + setRenamingPoseId(poseId); + }; + + const commitPoseRename = (poseId: string, raw: string) => { + setRenamingPoseId(null); + const poseIndex = displayPoses.findIndex( + (entry) => entry.poseId === poseId, + ); + if (poseIndex < 0) return; + const trimmed = raw.trim(); + // 무명 상태에서 표시 번호를 그대로 확정한 건 변경이 아니다 - + // 같은 번호로 다음 구조 변경에서 고정되므로 커밋을 만들지 않는다 + if ( + displayPoses[poseIndex].name == null && + trimmed === resolvedNames[poseIndex] + ) { + return; + } + // 빈 값은 자동 이름 재부여 - sticky 규약이라 null로 되돌리지 않고 + // 자기 이름을 뺀 점유 번호 기준으로 빈 번호를 준다 + const nextName = + trimmed === '' + ? resolvedPoseNames( + displayPoses.map((pose, index) => + index === poseIndex ? { ...pose, name: null } : pose, + ), + )[poseIndex] + : trimmed; + if ((displayPoses[poseIndex].name ?? null) === nextName) return; + replacePose(poseIndex, { name: nextName }); + }; + + const handlePoseMenuSelect = (actionId: string) => { + const poseId = poseMenu.renderKey; + poseMenu.close(); + if (poseId === null) return; + if (actionId === 'rename') { + startPoseRename(poseId); + return; + } + if (actionId === 'duplicate') { + clonePose(poseId); + return; + } + if (actionId === 'delete') { + const poseIndex = displayPoses.findIndex( + (entry) => entry.poseId === poseId, + ); + if (poseIndex < 0) return; + // 삭제 대상의 편집 팝업이 열려 있으면 함께 닫는다 + if ( + activeEditorTarget?.kind === 'pose' && + activeEditorTarget.poseId === poseId + ) { + setEditorTarget(null); + } + removePose(poseIndex); + } + }; const showInvalidImageAlert = (): void => { void window.api.ui.dialog @@ -378,17 +615,32 @@ export const SingleSpritePanel: React.FC = ({ }; const handleBaseImageSelect = async () => { + const requestedGeneration = asyncGenerationRef.current; const path = await pickImage(); - if (path) commitFields({ baseImage: path }); + if (!path) return; + // 파일창이 떠 있는 동안 대상 전환·언마운트가 지났으면 폐기 + if (asyncGenerationRef.current !== requestedGeneration) return; + commitFields({ baseImage: path }); }; - const handlePoseImageSelect = async (poseIndex: number) => { + const handlePoseImageSelect = async (poseId: string) => { + const requestedGeneration = asyncGenerationRef.current; const path = await pickImage(); - if (path) replacePose(poseIndex, { imageOverride: path }); + if (!path) return; + if (asyncGenerationRef.current !== requestedGeneration) return; + // 자세가 사라졌으면 폐기하고, 최신 poses에 다시 결합한다 + const latest = latestPosesRef.current; + if (!latest.some((pose) => pose.poseId === poseId)) return; + updatePoses( + latest.map((pose) => + pose.poseId === poseId ? { ...pose, imageOverride: path } : pose, + ), + ); }; const spriteTitle = position.layerName || 'Sprite'; const { anchor, transitionMs } = SPRITE_CONSTRAINTS; + const pivotPercent = (value: number) => Math.round(value * 1000) / 10; const easingValue = position.transitionEasing || DEFAULT_SPRITE_TRANSITION_EASING; const easingOptions = SPRITE_EASING_PRESETS.some( @@ -403,10 +655,8 @@ export const SingleSpritePanel: React.FC = ({ ...SPRITE_EASING_PRESETS, ]; - const pivotPercent = (value: number) => Math.round(value * 1000) / 10; - return ( -
+
{isRenaming ? ( = ({ className="properties-panel-overlay-viewport" > - {/* 이미지 */} + {/* 기본 이미지 - 미리보기·표시·위치·크기는 설정 팝업이 맡는다 */} - {position.baseImage ? ( - - ) : null} - - - - - commitFields({ imageFit: value as SpriteImageFit }) + aria-haspopup="dialog" + aria-expanded={imageEditing} + aria-label={ + t('propertiesPanel.spriteBaseImage') || '기본 이미지' } - /> - - - - - commitFields({ - imageRect: { ...position.imageRect, x: value }, - }) + className={`${ACTION_BUTTON_CLASS} ${ + imageEditing ? 'shadow-focus-ring' : '' + }`} + onClick={(event) => + openEditorPopup( + { kind: 'image', positionId: position.id }, + event.currentTarget, + ) } - onPreview={(value) => - previewFields({ - imageRect: { ...position.imageRect, x: value }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="X" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - - commitFields({ - imageRect: { ...position.imageRect, y: value }, - }) - } - onPreview={(value) => - previewFields({ - imageRect: { ...position.imageRect, y: value }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="Y" - width={AXIS_FIELD_WIDTH} - min={-9999} - max={9999} - allowDecimal - decimalScale={1} - /> - - - - - commitFields({ - imageRect: { - ...position.imageRect, - width: Math.max(1, value), - }, - }) - } - onPreview={(value) => - previewFields({ - imageRect: { - ...position.imageRect, - width: Math.max(1, value), - }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="W" - width={AXIS_FIELD_WIDTH} - min={1} - max={9999} - allowDecimal - decimalScale={1} - /> - - commitFields({ - imageRect: { - ...position.imageRect, - height: Math.max(1, value), - }, - }) - } - onPreview={(value) => - previewFields({ - imageRect: { - ...position.imageRect, - height: Math.max(1, value), - }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="H" - width={AXIS_FIELD_WIDTH} - min={1} - max={9999} - allowDecimal - decimalScale={1} - /> + > + {t('propertiesPanel.configure') || '설정하기'} + - {/* 정규화 좌표를 %로 표시 */} + {/* 기준점 - 회전·배율의 축, 이미지 상자 기준 비율(%). + 만지는 동안 캔버스에 십자 마커가 뜬다 */} - - commitFields({ - pivot: { - ...position.pivot, - x: clamp(value, 0, 100) / 100, - }, - }) - } - onPreview={(value) => - previewFields({ - pivot: { - ...position.pivot, - x: clamp(value, 0, 100) / 100, - }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="X" - suffix="%" - width={AXIS_FIELD_WIDTH} - min={anchor.min * 100} - max={anchor.max * 100} - allowDecimal - decimalScale={1} - /> - - commitFields({ - pivot: { - ...position.pivot, - y: clamp(value, 0, 100) / 100, - }, - }) - } - onPreview={(value) => - previewFields({ - pivot: { - ...position.pivot, - y: clamp(value, 0, 100) / 100, - }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="Y" - suffix="%" - width={AXIS_FIELD_WIDTH} - min={anchor.min * 100} - max={anchor.max * 100} - allowDecimal - decimalScale={1} - /> +
setPivotEngaged(true)} + onBlurCapture={(event) => { + if ( + !event.currentTarget.contains( + event.relatedTarget as Node | null, + ) + ) { + setPivotEngaged(false); + } + }} + onPointerDownCapture={() => setPivotEngaged(true)} + > + + commitFields({ + pivot: { + ...position.pivot, + x: clamp(value, 0, 100) / 100, + }, + }) + } + onPreview={(value) => + previewFields({ + pivot: { + ...position.pivot, + x: clamp(value, 0, 100) / 100, + }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="X" + suffix="%" + ariaLabel={`${ + t('propertiesPanel.spritePivot') || '기준점' + } X`} + width={AXIS_FIELD_WIDTH} + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> + + commitFields({ + pivot: { + ...position.pivot, + y: clamp(value, 0, 100) / 100, + }, + }) + } + onPreview={(value) => + previewFields({ + pivot: { + ...position.pivot, + y: clamp(value, 0, 100) / 100, + }, + }) + } + onCancel={() => editGestureController.cancel()} + prefix="Y" + suffix="%" + ariaLabel={`${ + t('propertiesPanel.spritePivot') || '기준점' + } Y`} + width={AXIS_FIELD_WIDTH} + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> +
- {/* 대기 자세 */} - -
-

- {t('propertiesPanel.spriteIdlePose') || '대기 자세'} -

-
- commitFields({ idleTransform: next })} - onPreview={(next) => previewFields({ idleTransform: next })} - t={t} - /> -
- - {/* 자세 목록: 자세마다 담당 키 + 변환 + 이미지 교체 */} - {displayPoses.map((pose, poseIndex) => { - const isDuplicate = duplicatePose?.poseId === pose.poseId; - const isEmpty = pose.triggers.length === 0; - const deadTriggers = pose.triggers.filter( - (id) => !keyOptionIds.has(id), - ); - return ( - -
-

- {`${t('propertiesPanel.spritePose') || '자세'} ${ - poseIndex + 1 - }`} -

- -
- -
-

- {t('propertiesPanel.spriteTriggerKeys') || '담당 키'} -

- {keyOptions.length === 0 && deadTriggers.length === 0 ? ( -

- {t('propertiesPanel.spriteNoKeys') || - '이 모드에 키 요소가 없습니다'} -

+ }} + onContextMenu={(event) => { + // 목록 영역은 바깥닫힘 예외라 메뉴를 열 때 직접 닫는다 + setEditorTarget(null); + poseMenu.openFromContextMenu(event, pose.poseId); + }} + className={`${pickerRowClass} ${ + isEditing || isRenamingPose + ? 'bg-fill-hover text-fg cursor-pointer' + : 'text-fg hover:bg-fill cursor-pointer' + }`} + title={displayName} + > + {isRenamingPose ? ( + + setPoseRenameValue(event.target.value) + } + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onBlur={() => { + if (!poseRenameCancelledRef.current) { + commitPoseRename(pose.poseId, poseRenameValue); + } + poseRenameCancelledRef.current = false; + }} + onKeyDown={(event) => { + // 피커 행 규약 - 행·전역 단축키로 전파하지 않는다 + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + (event.target as HTMLInputElement).blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + poseRenameCancelledRef.current = true; + setRenamingPoseId(null); + } + }} + spellCheck={false} + /> ) : ( -
- {keyOptions.map((option) => { - const selected = pose.triggers.includes(option.id); - return ( - - ); - })} - {/* 죽은 참조는 표시하고 클릭으로 제거 */} - {deadTriggers.map((id) => ( - - ))} -
+ + {displayName} + )} - {isEmpty || isDuplicate ? ( -

- {isEmpty - ? t('propertiesPanel.spriteEmptyTriggers') || - '담당 키를 선택하면 저장됩니다' - : t('propertiesPanel.spriteDuplicateTriggers') || - '같은 키 조합의 자세가 있어 저장되지 않습니다'} -

- ) : null} -
- - - replacePose(poseIndex, { transform: next }) - } - onPreview={ - posesCommittable - ? (next) => - previewFields({ - poses: displayPoses.map((entry, index) => - index === poseIndex - ? { ...entry, transform: next } - : entry, - ), - }) - : undefined - } - t={t} - /> - - - {pose.imageOverride ? ( - - ) : null} - -
- ); - })} - - +
+ ); + })} + {displayPoses.length < SPRITE_CONSTRAINTS.maxPoses ? ( + addPose(event.currentTarget)} + /> + ) : null} +
{/* 전환 */} @@ -868,6 +996,79 @@ export const SingleSpritePanel: React.FC = ({
+ + {/* 편집 팝업 - 이미지 설정·상태 편집이 한 자리를 배타로 쓴다 */} + + {imageEditing ? ( + editGestureController.cancel()} + onImagePick={() => void handleBaseImageSelect()} + onImageReset={() => commitFields({ baseImage: null })} + onClose={() => setEditorTarget(null)} + t={t} + /> + ) : editingPose ? ( + + togglePoseTrigger(editingPoseIndex, keyId), + onImagePick: () => void handlePoseImageSelect(editingPose.poseId), + onImageReset: () => + replacePose(editingPoseIndex, { imageOverride: null }), + }} + onTransformCommit={(next) => + replacePose(editingPoseIndex, { transform: next }) + } + onTransformPreview={ + posesCommittable + ? (next) => + previewFields({ + poses: displayPoses.map((entry, index) => + index === editingPoseIndex + ? { ...entry, transform: next } + : entry, + ), + }) + : undefined + } + onTransformCancel={() => editGestureController.cancel()} + onClose={() => setEditorTarget(null)} + t={t} + /> + ) : null} + + + {/* 행 ⋮ 메뉴 - 퇴장 모션 동안 renderKey가 유지된다 */} + {poseMenu.renderKey !== null && ( + + )} ); }; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx new file mode 100644 index 000000000..e43ba6883 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import PickerSurface from '@components/main/Grid/PropertiesPanel/PickerSurface'; +import Dropdown from '@components/main/common/Dropdown'; +import { NumberInput } from '@components/main/common/NumberInput'; +import { + PropertyRow, + PropertySection, +} from '@components/main/Grid/PropertiesPanel/PropertyInputs'; +import { resolveImageSource } from '@utils/core/imageSource'; +import { + type ReactiveSpritePosition, + type SpriteImageFit, +} from '@src/types/key/sprites'; + +interface SpriteImageSettingsPopupProps { + open: boolean; + position: Pick< + ReactiveSpritePosition, + 'baseImage' | 'imageFit' | 'imageRect' + >; + referenceRef: React.RefObject; + panelElement: HTMLElement | null; + onCommit: (patch: Partial) => void; + onPreview: (patch: Record) => void; + onCancel: () => void; + /** 파일 선택·디코드 검증·대상 재확인은 패널이 소유한다 */ + onImagePick: () => void; + onImageReset: () => void; + onClose: () => void; + t: (key: string) => string; +} + +// 기본 이미지 설정 팝업 - 이미지 상자(미리보기·위치·크기·표시)를 +// 커스텀 이미지 전례(행 + 설정하기 + 피커 카드)로 묶은 표현 컴포넌트. +// 카드 규격·미리보기 문법은 이미지 피커와 동일. 기준점은 움직임 속성이라 패널 몫 +const SpriteImageSettingsPopup: React.FC = ({ + open, + position, + referenceRef, + panelElement, + onCommit, + onPreview, + onCancel, + onImagePick, + onImageReset, + onClose, + t, +}) => { + const fit = position.imageFit ?? 'contain'; + const imageSrc = resolveImageSource(position.baseImage); + + const rectField = (patch: Partial) => ({ + imageRect: { ...position.imageRect, ...patch }, + }); + + return ( + + {/* 이미지 미리보기 - 전면 선택 버튼과 형제 초기화 버튼 (중첩 버튼 금지) */} +
+ {/* 투명 격자 배경 */} +
+ {imageSrc ? ( + + ) : null} + + ) : null} +
+ + {/* 위치·크기·기준점 - 접두 글자와 % 접미로 구분하는 무라벨 그리드 */} +
+
+ onCommit(rectField({ x: value }))} + onPreview={(value) => onPreview(rectField({ x: value }))} + onCancel={onCancel} + prefix="X" + ariaLabel={`${t('propertiesPanel.spriteImageRect') || '위치'} X`} + width="100%" + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> + onCommit(rectField({ y: value }))} + onPreview={(value) => onPreview(rectField({ y: value }))} + onCancel={onCancel} + prefix="Y" + ariaLabel={`${t('propertiesPanel.spriteImageRect') || '위치'} Y`} + width="100%" + min={-9999} + max={9999} + allowDecimal + decimalScale={1} + /> +
+
+ + onCommit(rectField({ width: Math.max(1, value) })) + } + onPreview={(value) => + onPreview(rectField({ width: Math.max(1, value) })) + } + onCancel={onCancel} + prefix="W" + ariaLabel={`${t('propertiesPanel.spriteImageSize') || '크기'} W`} + width="100%" + min={1} + max={9999} + allowDecimal + decimalScale={1} + /> + + onCommit(rectField({ height: Math.max(1, value) })) + } + onPreview={(value) => + onPreview(rectField({ height: Math.max(1, value) })) + } + onCancel={onCancel} + prefix="H" + ariaLabel={`${t('propertiesPanel.spriteImageSize') || '크기'} H`} + width="100%" + min={1} + max={9999} + allowDecimal + decimalScale={1} + /> +
+
+ + {/* 설정 카드 */} + + + + onCommit({ imageFit: value as SpriteImageFit }) + } + /> + + + + ); +}; + +export default SpriteImageSettingsPopup; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx new file mode 100644 index 000000000..6c79e4467 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import PickerSurface from '@components/main/Grid/PropertiesPanel/PickerSurface'; +import Dropdown from '@components/main/common/Dropdown'; +import { NumberInput } from '@components/main/common/NumberInput'; +import { + AngleGlyph, + ScaleGlyph, +} from '@components/main/common/TransformGlyphs'; +import { + PropertyRow, + PropertySection, +} from '@components/main/Grid/PropertiesPanel/PropertyInputs'; +import { ACTION_BUTTON_CLASS } from '@utils/cardRecipes'; +import { + SPRITE_CONSTRAINTS, + type SpriteTransform, +} from '@src/types/key/sprites'; + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +// 담당 키·이미지 오버라이드 컨트롤 묶음 (삭제·이름 변경은 행 메뉴가 맡는다) +export interface SpritePoseControls { + keyOptions: ReadonlyArray<{ id: string; label: string }>; + triggers: readonly string[]; + isDuplicate: boolean; + hasImageOverride: boolean; + onToggleTrigger: (keyId: string) => void; + onImagePick: () => void; + onImageReset: () => void; +} + +interface SpritePoseEditorPopupProps { + open: boolean; + ariaLabel: string; + transform: SpriteTransform; + referenceRef: React.RefObject; + panelElement: HTMLElement | null; + poseControls: SpritePoseControls; + // 행 전환 시 바깥닫힘을 거치지 않는 영역 (상태 목록 well) + interactiveRefs?: React.RefObject[]; + onTransformCommit: (next: SpriteTransform) => void; + onTransformPreview?: (next: SpriteTransform) => void; + onTransformCancel: () => void; + onClose: () => void; + t: (key: string) => string; +} + +// 상태 하나의 편집 팝업 - 담당 키·변환·상태 이미지를 모아 패널 행을 요약으로 남긴다. +// 카드 규격·변환 그리드는 이미지 피커와 동일 +const SpritePoseEditorPopup: React.FC = ({ + open, + ariaLabel, + transform, + referenceRef, + panelElement, + poseControls, + interactiveRefs, + onTransformCommit, + onTransformPreview, + onTransformCancel, + onClose, + t, +}) => { + const { offset, rotation, scale } = SPRITE_CONSTRAINTS; + const commitField = (patch: Partial) => + onTransformCommit({ ...transform, ...patch }); + const previewField = onTransformPreview + ? (patch: Partial) => + onTransformPreview({ ...transform, ...patch }) + : undefined; + + const keyOptionIds = new Set( + poseControls.keyOptions.map((option) => option.id), + ); + const deadTriggers = poseControls.triggers.filter( + (id) => !keyOptionIds.has(id), + ); + + return ( + + {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? ( +

+ {t('propertiesPanel.spriteNoKeys') || '이 모드에 키 요소가 없습니다'} +

+ ) : ( + ({ + label: option.label, + value: option.id, + })), + // 죽은 참조도 항목으로 노출해 토글로 제거 + ...deadTriggers.map((id) => ({ + label: t('propertiesPanel.spriteMissingKey') || '삭제된 키', + value: id, + danger: true, + })), + ]} + multiple + values={[...poseControls.triggers]} + onChange={poseControls.onToggleTrigger} + placeholder={ + t('propertiesPanel.spriteTriggerPlaceholder') || '키 선택' + } + fullWidth + // 중복 조합은 배너 대신 트리거 자체가 짧은 라벨 + 위험 톤 + 사유 툴팁 + danger={poseControls.isDuplicate} + dangerLabel={t('propertiesPanel.spriteDuplicateShort') || '중복 키'} + title={ + poseControls.isDuplicate + ? t('propertiesPanel.spriteDuplicateTriggers') || + '중복된 키 조합입니다' + : undefined + } + /> + )} + + {/* 위치·회전·배율 - 라벨 대신 접두 글리프가 의미를 맡는다 */} +
+
+ + commitField({ x: clamp(value, offset.min, offset.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ x: clamp(value, offset.min, offset.max) }) + : undefined + } + onCancel={onTransformCancel} + prefix="X" + ariaLabel={`${t('propertiesPanel.position') || '위치'} X`} + width="100%" + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> + + commitField({ y: clamp(value, offset.min, offset.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ y: clamp(value, offset.min, offset.max) }) + : undefined + } + onCancel={onTransformCancel} + prefix="Y" + ariaLabel={`${t('propertiesPanel.position') || '위치'} Y`} + width="100%" + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> +
+
+ + commitField({ + rotation: clamp(value, rotation.min, rotation.max), + }) + } + onPreview={ + previewField + ? (value) => + previewField({ + rotation: clamp(value, rotation.min, rotation.max), + }) + : undefined + } + onCancel={onTransformCancel} + prefix={} + ariaLabel={t('propertiesPanel.spriteRotation') || '회전'} + suffix="°" + width="100%" + min={rotation.min} + max={rotation.max} + allowDecimal + decimalScale={1} + /> + {/* 배율은 이미지 피커와 같은 정수 % 표기 - 저장은 배수 그대로 */} + + commitField({ scale: clamp(value / 100, scale.min, scale.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ + scale: clamp(value / 100, scale.min, scale.max), + }) + : undefined + } + onCancel={onTransformCancel} + prefix={} + ariaLabel={t('propertiesPanel.spriteScale') || '배율'} + suffix="%" + width="100%" + min={scale.min * 100} + max={scale.max * 100} + /> +
+
+ + {/* 상태 이미지 - 안 고르면 기본 이미지가 그대로 쓰인다 */} + + + + {poseControls.hasImageOverride ? ( + + ) : null} + + +
+ ); +}; + +export default SpritePoseEditorPopup; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index a95b16ed0..95854a25e 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -7,7 +7,9 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SpriteItem from '@components/main/Grid/layers/SpriteItem'; +import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { SpritePose } from '@src/types/key/sprites'; ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -98,6 +100,7 @@ describe('SpriteItem 렌더', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + useSpriteEditPreviewStore.setState({ preview: null }); }); it('선택 여부에 따라 활동 영역 테두리가 가이드와 선택 색을 오간다', () => { @@ -119,4 +122,228 @@ describe('SpriteItem 렌더', () => { const node = renderSprite(spritePosition({ hidden: true })); expect(node).toBeNull(); }); + + it('useInlineStyles=false는 이미지 외관 채널을 변수로만 싣는다', () => { + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: false, + idleTransform: { x: 4, y: -2, rotation: 30, scale: 1.5 }, + }), + ); + + const img = node?.querySelector('img'); + // 인라인 선언이 비어야 사용자 CSS가 !important 없이 이긴다 + expect(img?.style.transform).toBe(''); + expect(img?.style.objectFit).toBe(''); + expect(img?.style.getPropertyValue('--dmn-sprite-fit-default')).toBe( + 'contain', + ); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + 'translate(4px, -2px) rotate(30deg) scale(1.5)', + ); + // 정적 idle 렌더라 transition 채널 없음 + expect(img?.style.getPropertyValue('--dmn-sprite-transition-default')).toBe( + '', + ); + // 배치·기준점은 모드와 무관하게 인라인 + expect(img?.style.width).toBe('200px'); + expect(img?.style.transformOrigin).toBe('50% 50%'); + }); + + it('useInlineStyles=true는 이미지 외관 채널을 인라인 선언으로 승격한다', () => { + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + idleTransform: { x: 4, y: -2, rotation: 30, scale: 1.5 }, + }), + ); + + const img = node?.querySelector('img'); + expect(img?.style.transform).toBe( + 'translate(4px, -2px) rotate(30deg) scale(1.5)', + ); + expect(img?.style.objectFit).toBe('contain'); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + '', + ); + }); +}); + +describe('SpriteItem 자세 편집 프리뷰', () => { + const pose = ( + poseId: string, + overrides: Partial = {}, + ): SpritePose => ({ + poseId, + triggers: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], + matchMode: 'exact', + transform: { x: 10, y: 5, rotation: 45, scale: 2 }, + imageOverride: null, + ...overrides, + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useSpriteEditPreviewStore.setState({ preview: null }); + }); + + it('발행된 자세는 composed poses의 최신 값이 스냅샷보다 우선한다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + transform: { x: 99, y: 99, rotation: 0, scale: 1 }, + }), + preferFallback: false, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [ + pose('pose-1', { + imageOverride: 'data:image/png;base64,override', + }), + ], + }), + ); + + const img = node?.querySelector('img'); + expect(img?.style.transform).toBe( + 'translate(10px, 5px) rotate(45deg) scale(2)', + ); + expect(img?.getAttribute('src')).toContain('override'); + }); + + it('이미지 설정 발행은 기준점 마커를 그리고 회수하면 사라진다', () => { + act(() => + useSpriteEditPreviewStore + .getState() + .publish({ kind: 'pivot', positionId: SPRITE_ID }), + ); + const node = renderSprite( + spritePosition({ + pivot: { x: 0.25, y: 1 }, + imageRect: { x: 10, y: 20, width: 100, height: 80 }, + }), + ); + + const marker = node?.querySelector( + '[data-sprite-pivot-marker="true"]', + ); + expect(marker).not.toBeNull(); + expect(marker?.style.left).toBe('35px'); + expect(marker?.style.top).toBe('100px'); + + act(() => useSpriteEditPreviewStore.getState().clear()); + expect(node?.querySelector('[data-sprite-pivot-marker="true"]')).toBeNull(); + }); + + it('무효 draft 편집은 canonical 값 대신 스냅샷을 우선한다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + triggers: [], + transform: { x: 3, y: 4, rotation: 0, scale: 1 }, + }), + preferFallback: true, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [pose('pose-1')], + }), + ); + + const img = node?.querySelector('img'); + expect(img?.style.transform).toBe( + 'translate(3px, 4px) rotate(0deg) scale(1)', + ); + }); + + it('canonical에 없는 draft 자세는 발행 스냅샷으로 그리고 이미지는 base로 남는다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'draft-1', + fallbackPose: pose('draft-1', { + triggers: [], + transform: { x: 7, y: 0, rotation: 0, scale: 1 }, + }), + preferFallback: false, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [], + }), + ); + + const img = node?.querySelector('img'); + expect(img?.style.transform).toBe( + 'translate(7px, 0px) rotate(0deg) scale(1)', + ); + expect(img?.getAttribute('src')).toContain('base64,base'); + }); + + it('다른 스프라이트의 발행은 무시하고 회수하면 대기 상태로 복귀한다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: '00000000-0000-4000-8000-000000000999', + poseId: 'pose-1', + fallbackPose: pose('pose-1'), + preferFallback: false, + }), + ); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + idleTransform: { x: 1, y: 2, rotation: 0, scale: 1 }, + }), + ); + const img = () => node?.querySelector('img'); + expect(img()?.style.transform).toBe( + 'translate(1px, 2px) rotate(0deg) scale(1)', + ); + + // 자기 발행으로 전환 후 회수하면 대기 상태로 복귀 + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1'), + preferFallback: false, + }), + ); + expect(img()?.style.transform).toBe( + 'translate(10px, 5px) rotate(45deg) scale(2)', + ); + act(() => useSpriteEditPreviewStore.getState().clear()); + expect(img()?.style.transform).toBe( + 'translate(1px, 2px) rotate(0deg) scale(1)', + ); + }); }); diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index a8261d9ef..ed8b6072d 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -4,8 +4,9 @@ import { useDraggable, useSmartGuidesElements } from '@hooks/Grid'; import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; import { useSettingsStore } from '@stores/useSettingsStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { useSpriteEditPreview } from '@stores/grid/useSpriteEditPreviewStore'; import { resolveImageSource } from '@utils/core/imageSource'; -import { spriteTransformToCss } from '@src/types/key/sprites'; +import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; interface SelectedElement { @@ -48,8 +49,8 @@ interface SpriteItemProps { const ACTIVITY_AREA_BORDER = '1px dashed rgba(237, 238, 242, 0.4)'; const SELECTED_AREA_BORDER = '1px solid var(--ui-selection-border)'; -// 캔버스의 스프라이트는 정적이다: idle transform 상태만 그린다. -// 키 눌림 라이브 반응은 오버레이 창(OverlaySpriteItem) 몫 +// 캔버스의 스프라이트는 정적이다: 평소엔 idle 상태, 자세 팝업이 열려 있으면 +// 그 자세를 그린다 (편집창 전용 프리뷰). 키 눌림 라이브 반응은 오버레이 창 몫 const SpriteItem = ({ index, elementId, @@ -88,7 +89,23 @@ const SpriteItem = ({ const isSelectionMode = isSelected; - const imageSrc = resolveImageSource(position.baseImage); + // 편집 중 프리뷰 - 자세 팝업이면 그 자세를 그린다. 유효 자세는 composed poses가 + // 최신(스크럽 프리뷰 병합)이라 우선하고, 무효 draft(preferFallback)나 canonical에 + // 없는 신규 자세는 발행된 스냅샷으로 그린다. transform과 이미지는 같은 자세에서 + // 함께 파생 (혼합 상태 방지). 기준점 편집 중이면 축 마커만 얹는다 + const editPreview = useSpriteEditPreview(elementId); + const posePreview = editPreview?.kind === 'pose' ? editPreview : null; + const previewPose = posePreview + ? posePreview.preferFallback + ? posePreview.fallbackPose + : position.poses.find((pose) => pose.poseId === posePreview.poseId) ?? + posePreview.fallbackPose + : null; + const showPivotMarker = editPreview?.kind === 'pivot'; + + const imageSrc = resolveImageSource( + previewPose?.imageOverride ?? position.baseImage, + ); const draggable = useDraggable({ gridSize: gridSnapSize, @@ -245,16 +262,11 @@ const SpriteItem = ({ alt="" draggable={false} style={{ - position: 'absolute', - left: `${position.imageRect.x}px`, - top: `${position.imageRect.y}px`, - width: `${position.imageRect.width}px`, - height: `${position.imageRect.height}px`, - objectFit: position.imageFit ?? 'contain', - transformOrigin: `${position.pivot.x * 100}% ${ - position.pivot.y * 100 - }%`, - transform: spriteTransformToCss(position.idleTransform), + // 정적 렌더라 transition 채널 없음, 외관 채널 규칙은 오버레이와 동일 + ...computeSpriteImageStyle( + position, + previewPose ? previewPose.transform : position.idleTransform, + ), pointerEvents: 'none', userSelect: 'none', }} @@ -299,6 +311,48 @@ const SpriteItem = ({
)} + {showPivotMarker ? ( + // 기준점 마커 - 회전·배율 축의 위치를 실시간으로 보여준다 (기준점 편집 동안) +
+ +
+ ) : null} ); diff --git a/src/renderer/components/main/Modal/PopupExit.test.tsx b/src/renderer/components/main/Modal/PopupExit.test.tsx index 1e3cf3072..92789334d 100644 --- a/src/renderer/components/main/Modal/PopupExit.test.tsx +++ b/src/renderer/components/main/Modal/PopupExit.test.tsx @@ -127,6 +127,37 @@ describe('PopupExit', () => { expect(onCommit).toHaveBeenCalledWith('fill'); }); + it('splits the session when the child key changes while open', async () => { + const onMount = vi.fn(); + const onCommit = vi.fn(); + + const renderKeyed = async (target: string) => { + await act(async () => { + root.render( + + + , + ); + }); + }; + + await renderKeyed('fill'); + expect(onMount).toHaveBeenCalledTimes(1); + + // 닫힘 없이 대상만 교체 (상태 행 전환) - 세션이 끊겨 새 인스턴스가 마운트된다 + await renderKeyed('stroke'); + expect(onMount).toHaveBeenCalledTimes(2); + // 이전 세션의 언마운트 커밋은 자기 대상으로 간다 + expect(onCommit).toHaveBeenCalledWith('fill'); + expect(onCommit).not.toHaveBeenCalledWith('stroke'); + }); + // 이 조합이 실제 payoff다 - 호출부가 대상을 비워도 팝업이 퇴장 상태를 거친다 it('drives a real popup through the closing state before unmount', async () => { vi.spyOn(Element.prototype, 'getClientRects').mockReturnValue([ diff --git a/src/renderer/components/main/Modal/PopupExit.tsx b/src/renderer/components/main/Modal/PopupExit.tsx index 0a91858a1..250c3ec1e 100644 --- a/src/renderer/components/main/Modal/PopupExit.tsx +++ b/src/renderer/components/main/Modal/PopupExit.tsx @@ -37,9 +37,11 @@ const PopupExit = ({ open, children }: PopupExitProps) => { if (!mounted || !retained) return null; + // 호출부가 child에 key를 주면 열린 채 대상이 바뀔 때도 세션이 끊긴다 + // (상태 행 전환처럼 닫힘 없이 편집 대상을 교체하는 경우) return React.cloneElement(retained as React.ReactElement<{ open: boolean }>, { open, - key: session, + key: `${session}:${retained.key ?? ''}`, }); }; diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx index 449d864d2..1f1323c9c 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx @@ -5,6 +5,10 @@ import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import { NumberInput } from '@components/main/common/NumberInput'; import TabSwitch from '@components/main/common/TabSwitch'; +import { + AngleGlyph, + ScaleGlyph, +} from '@components/main/common/TransformGlyphs'; import { PropertySection } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; import { resolveImageSource } from '@utils/core/imageSource'; import { canDecodeImage } from '@utils/core/assetProbe'; @@ -72,43 +76,6 @@ interface ImagePickerProps { completionBinding?: CompletionBinding; } -// 회전·크기 입력의 접두 글리프 - X/Y 글자와 같은 자리 -const AngleGlyph = () => ( - -); - -const ScaleGlyph = () => ( - -); - const STATE_MODES = { idle: 'idle', active: 'active', @@ -220,6 +187,10 @@ const ImagePicker = ({ const showImageFit = typeof onIdleImageFitChange === 'function' || typeof onActiveImageFitChange === 'function'; + // 투명화 토글도 imageFit과 같은 관례 - 콜백 없는 호출처(스프라이트)에선 숨김 + const showTransparentToggle = + typeof onIdleTransparentChange === 'function' || + typeof onActiveTransparentChange === 'function'; const handleTransparentToggle = (): void => { if (effectiveMode === STATE_MODES.idle) { onIdleTransparentChange?.(!idleTransparent); @@ -441,68 +412,76 @@ const ImagePicker = ({ )} - {/* 설정 카드 */} - - {/* 키 투명화 토글 */} -
-

- {t('imagePicker.transparent')} -

- -
+ {/* 설정 카드 - 노출할 컨트롤이 하나도 없으면(스프라이트) 카드 생략 */} + {(showTransparentToggle || + showImageFit || + typeof onImageModeChange === 'function') && ( + + {/* 키 투명화 토글 */} + {showTransparentToggle && ( +
+

+ {t('imagePicker.transparent')} +

+ +
+ )} - {/* 이미지 맞춤 */} - {showImageFit && ( -
-

- {t('propertiesPanel.imageFit') || '표시'} -

- -
- )} + {/* 이미지 맞춤 */} + {showImageFit && ( +
+

+ {t('propertiesPanel.imageFit') || '표시'} +

+ +
+ )} - {/* 배치 모드 - 공통 */} - {typeof onImageModeChange === 'function' && ( -
-

{t('imagePicker.mode')}

- onImageModeChange(value as ImageMode)} - /> -
- )} -
+ {/* 배치 모드 - 공통 */} + {typeof onImageModeChange === 'function' && ( +
+

+ {t('imagePicker.mode')} +

+ onImageModeChange(value as ImageMode)} + /> +
+ )} +
+ )}
); }; diff --git a/src/renderer/components/main/common/Dropdown.test.tsx b/src/renderer/components/main/common/Dropdown.test.tsx index fad53e0dc..754296aaa 100644 --- a/src/renderer/components/main/common/Dropdown.test.tsx +++ b/src/renderer/components/main/common/Dropdown.test.tsx @@ -334,6 +334,67 @@ describe('Dropdown keyboard contract', () => { expect(trigger?.getAttribute('aria-expanded')).toBe('false'); }); + it('다중 모드는 항목 클릭에도 닫히지 않고 체크 상태를 토글로 보여준다', async () => { + const onChange = vi.fn(); + const renderMulti = async (values: string[]) => { + await act(async () => { + root.render( + , + ); + }); + }; + const optionByText = (text: string) => + [...document.querySelectorAll('[role="option"]')].find( + (option) => option.textContent === text, + )!; + + // 빈 선택은 자리표시 문구 + await renderMulti([]); + const trigger = host.querySelector( + '[aria-haspopup="listbox"]', + ); + expect(trigger?.textContent).toContain('키 선택'); + + await renderMulti(['one']); + expect(trigger?.textContent).toContain('One'); + await act(async () => trigger?.click()); + + expect(openListbox()?.getAttribute('aria-multiselectable')).toBe('true'); + expect(optionByText('One').getAttribute('aria-selected')).toBe('true'); + expect(optionByText('Two').getAttribute('aria-selected')).toBe('false'); + + await act(async () => optionByText('Two').click()); + expect(onChange).toHaveBeenCalledWith('two'); + expect(openListbox()).not.toBeNull(); + + // 컨트롤드 갱신 후 체크와 트리거 요약 반영 + await renderMulti(['one', 'two']); + expect(optionByText('Two').getAttribute('aria-selected')).toBe('true'); + expect(trigger?.textContent).toContain('One, Two'); + + // 키보드 선택도 메뉴를 유지한다 + await act(async () => { + optionByText('One').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + }); + expect(onChange).toHaveBeenCalledWith('one'); + expect(openListbox()).not.toBeNull(); + }); + it('closes itself before its parent modal on Escape', async () => { const closeModal = vi.fn(); await act(async () => { diff --git a/src/renderer/components/main/common/Dropdown.tsx b/src/renderer/components/main/common/Dropdown.tsx index 4b83d71dc..74c79ffa0 100644 --- a/src/renderer/components/main/common/Dropdown.tsx +++ b/src/renderer/components/main/common/Dropdown.tsx @@ -24,11 +24,14 @@ import { CANVAS_POPUP_CHROME_CLASS } from '../Modal/popupChrome'; interface DropdownOption { label: string; value: string; + /** danger 톤 텍스트 표시 (삭제된 참조 등) */ + danger?: boolean; } interface DropdownProps { options: DropdownOption[]; - value: string; + /** 단일 모드 선택 값 (다중 모드에서는 미사용) */ + value?: string; onChange: (value: string) => void; commitStrategy?: CommitStrategy; placeholder?: string; @@ -43,8 +46,18 @@ interface DropdownProps { widthClass?: string; /** 아이콘 트리거의 접근 가능한 이름 - 아이콘만 있는 버튼은 용도·현재 값이 안 읽힌다 */ ariaLabel?: string; - /** 트리거 크기 — sm: 24px 크롬(기본), lg: 30px 크롬(패널 페이지) */ + /** 트리거 크기 — sm: 23px 크롬(기본), lg: 30px 크롬(패널 페이지) */ size?: 'sm' | 'lg'; + /** 다중 선택 모드: 항목 클릭이 메뉴를 닫지 않는 토글이 되고 onChange가 토글된 값을 받음 */ + multiple?: boolean; + /** 다중 모드의 선택 값 목록, 체크 표시와 트리거 요약의 기준 */ + values?: string[]; + /** 현재 값이 충돌 상태 - 트리거 요약을 danger 톤으로 표시 */ + danger?: boolean; + /** danger 상태에서 요약 대신 표시할 짧은 라벨 (긴 요약·번역 잘림 방지) */ + dangerLabel?: string; + /** 트리거 툴팁 (충돌 사유 등) */ + title?: string; } // 확정 픽셀 좌표 — 전부 레이아웃 속성이라 transform 합성 지연과 무관. @@ -60,7 +73,7 @@ interface MenuPosition { const Dropdown: React.FC = ({ options, - value, + value = '', onChange, commitStrategy = 'sync', placeholder = '선택', @@ -71,6 +84,11 @@ const Dropdown: React.FC = ({ widthClass = '', size = 'sm', ariaLabel, + multiple = false, + values, + danger = false, + dangerLabel, + title, }) => { // 분리 패널 창 안에서는 그 창 기준으로 배치·포털·바깥 클릭 처리 const { window: ownerWindow, document: ownerDocument } = usePanelHost(); @@ -99,9 +117,10 @@ const Dropdown: React.FC = ({ motionRef: menuRef, }); - const selectedIndex = options.findIndex( - (option) => option.value === visualValue, - ); + const selectedValues = values ?? []; + const selectedIndex = multiple + ? options.findIndex((option) => selectedValues.includes(option.value)) + : options.findIndex((option) => option.value === visualValue); const openMenu = useCallback( (preferredIndex?: number) => { @@ -143,6 +162,11 @@ const Dropdown: React.FC = ({ (index: number) => { const option = options[index]; if (!option) return; + // 다중 모드는 토글이라 메뉴를 닫지 않는다 + if (multiple) { + onChange(option.value); + return; + } if (commitStrategy === 'sync') { commitSelection(option.value); closeAndFocusTrigger(); @@ -151,7 +175,14 @@ const Dropdown: React.FC = ({ closeAndFocusTrigger(); commitSelection(option.value); }, - [closeAndFocusTrigger, commitSelection, commitStrategy, options], + [ + closeAndFocusTrigger, + commitSelection, + commitStrategy, + multiple, + onChange, + options, + ], ); const moveActiveOption = useCallback( @@ -419,6 +450,19 @@ const Dropdown: React.FC = ({ }, [open, ownerDocument, ownerWindow]); const selected = options.find((opt) => opt.value === visualValue); + // 다중 모드 트리거 요약: 선택 라벨 나열, 없으면 placeholder + const selectedLabels = multiple + ? options + .filter((opt) => selectedValues.includes(opt.value)) + .map((opt) => opt.label) + : null; + const triggerText = selectedLabels + ? selectedLabels.length > 0 + ? selectedLabels.join(', ') + : null + : selected + ? selected.label + : null; const menu = mounted && anchor @@ -432,6 +476,7 @@ const Dropdown: React.FC = ({ // 닫히는 중엔 시각 잔상만 남으므로 포커스·스크린리더 대상에서 뺀다 inert={motionState === 'closing'} role="listbox" + aria-multiselectable={multiple || undefined} id={menuId} className={`dmn-motion fixed flex flex-col p-[5px] gap-[4px] ${CANVAS_POPUP_CHROME_CLASS} rounded-surface z-[var(--z-chrome-submenu)] overflow-x-hidden overflow-y-auto max-h-[200px] ${widthClass}`} style={{ @@ -451,28 +496,60 @@ const Dropdown: React.FC = ({ 옵션 없음 ) : ( - options.map((opt, index) => ( - - )) + options.map((opt, index) => { + const isSelected = multiple + ? selectedValues.includes(opt.value) + : visualValue === opt.value; + return ( + + ); + }) )} , ownerDocument.body, @@ -508,6 +585,7 @@ const Dropdown: React.FC = ({ aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? menuId : undefined} + title={title} className={`flex box-border items-center justify-between ${ size === 'lg' ? 'h-[30px] px-[10px] rounded-surface' @@ -519,8 +597,12 @@ const Dropdown: React.FC = ({ onKeyDown={handleTriggerKeyDown} disabled={disabled} > - - {selected ? selected.label : placeholder} + + {danger && dangerLabel ? dangerLabel : triggerText ?? placeholder} {/* viewBox 14를 8px로 렌더 - 스트로크 2.1이 화면상 1.2 */} ( + +); + +export const ScaleGlyph = () => ( + +); diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 5e3856124..271babbf8 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -9,7 +9,8 @@ import { import { resolveImageSource } from '@utils/core/imageSource'; import { warmupImageSource } from '@utils/core/imageWarmup'; import { resolveSpriteTarget } from '@utils/sprite/poseResolver'; -import { spriteTransformToCss } from '@src/types/key/sprites'; +import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; +import { resolveSpriteRenderEasing } from '@utils/sprite/spriteReach'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; interface OverlaySpriteItemProps { @@ -102,17 +103,15 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ alt="" draggable={false} style={{ - position: 'absolute', - left: `${position.imageRect.x}px`, - top: `${position.imageRect.y}px`, - width: `${position.imageRect.width}px`, - height: `${position.imageRect.height}px`, - objectFit: position.imageFit ?? 'contain', - transformOrigin: `${position.pivot.x * 100}% ${ - position.pivot.y * 100 - }%`, - transform: spriteTransformToCss(target.transform), - transition: `transform ${position.transitionMs}ms ${position.transitionEasing}`, + // 외관 채널은 기본 모드에서 변수로 - 사용자 CSS가 !important 없이 이긴다 + ...computeSpriteImageStyle( + position, + target.transform, + // 발산 easing은 도달 계산과 같은 기준으로 강등 (창 클리핑 방지) + `transform ${position.transitionMs}ms ${resolveSpriteRenderEasing( + position.transitionEasing, + )}`, + ), willChange: 'transform', }} onError={(event) => { diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index cea225fb7..0d8676ff9 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -112,11 +112,12 @@ describe('OverlaySpriteItem', () => { const img = imgEl(); expect(img?.getAttribute('src')).toBe(BASE_IMAGE); - expect(img?.style.transform).toBe( + // 기본 모드는 외관 채널을 변수로만 싣는다 - 전역 :where 규칙 소비 + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( 'translate(0px, 0px) rotate(0deg) scale(1)', ); expect(img?.style.transformOrigin).toBe('50% 100%'); - expect(img?.style.transition).toBe( + expect(img?.style.getPropertyValue('--dmn-sprite-transition-default')).toBe( 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', ); }); @@ -126,15 +127,67 @@ describe('OverlaySpriteItem', () => { act(() => setKeyActive('KeyA', true)); expect(spriteEl()?.dataset.state).toBe('active'); - expect(imgEl()?.style.transform).toBe( - 'translate(10px, -6px) rotate(15deg) scale(1.2)', - ); + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transform-default'), + ).toBe('translate(10px, -6px) rotate(15deg) scale(1.2)'); act(() => setKeyActive('KeyA', false)); expect(spriteEl()?.dataset.state).toBe('idle'); - expect(imgEl()?.style.transform).toBe( + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transform-default'), + ).toBe('translate(0px, 0px) rotate(0deg) scale(1)'); + }); + + it('useInlineStyles=false는 외관 채널을 인라인 선언 없이 변수로만 싣는다', () => { + render(makeSprite({ useInlineStyles: false, imageFit: 'cover' })); + + const img = imgEl(); + // 인라인 선언이 비어야 사용자 CSS가 !important 없이 이긴다 + expect(img?.style.transform).toBe(''); + expect(img?.style.transition).toBe(''); + expect(img?.style.objectFit).toBe(''); + expect(img?.style.getPropertyValue('--dmn-sprite-fit-default')).toBe( + 'cover', + ); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( 'translate(0px, 0px) rotate(0deg) scale(1)', ); + expect(img?.style.getPropertyValue('--dmn-sprite-transition-default')).toBe( + 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', + ); + // 배치·기준점은 모드와 무관하게 인라인 + expect(img?.style.left).toBe('10px'); + expect(img?.style.transformOrigin).toBe('50% 100%'); + }); + + it('발산 easing은 전환에서 폴백 곡선으로 강등된다', () => { + render(makeSprite({ transitionEasing: 'cubic-bezier(0.5, 10, 0.5, -9)' })); + + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transition-default'), + ).toBe('transform 90ms ease'); + }); + + it('useInlineStyles=true는 외관 채널을 인라인 선언으로 승격한다', () => { + render(makeSprite({ useInlineStyles: true, imageFit: 'cover' })); + + const img = imgEl(); + expect(img?.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(img?.style.transition).toBe( + 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', + ); + expect(img?.style.objectFit).toBe('cover'); + expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + '', + ); + + // 눌림 전환도 인라인 transform으로 반영 + act(() => setKeyActive('KeyA', true)); + expect(imgEl()?.style.transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); }); it('매핑에 없는 트리거는 눌림을 무시한다', () => { diff --git a/src/renderer/components/shared/OverlayScene.tsx b/src/renderer/components/shared/OverlayScene.tsx index b972222f2..5b6986437 100644 --- a/src/renderer/components/shared/OverlayScene.tsx +++ b/src/renderer/components/shared/OverlayScene.tsx @@ -105,9 +105,10 @@ interface OverlaySceneProps { keyCounterEnabled: boolean; // 선택적 - // 배경 박스 크기 - 미지정 시 뷰포트 전체 - // 데스크톱 창은 콘텐츠 박스와 크기가 같아 동일하고, OBS 소스에서는 남는 영역이 투명으로 남는다 - contentSize?: { width: number; height: number }; + // 배경 박스 - 미지정 시 뷰포트 전체. + // x·y는 스프라이트 이미지 도달 여유로 창 원점이 밀린 만큼의 배경 위치 보정이고, + // 배경 밖 여유 영역과 OBS 소스의 남는 영역은 투명으로 남는다 + contentSize?: { x?: number; y?: number; width: number; height: number }; // 전환 중 콘텐츠 페이드 - 네이티브 창 알파 미지원 환경에서 리사이즈 아티팩트를 가린다 contentFade?: { opacity: number; durationMs: number } | null; // 초기 리빌 게이트 - false면 모든 요소가 자리 잡을 때까지 화면을 감춘다 @@ -163,11 +164,14 @@ const OverlayScene = ({ }), }} > - {/* 배경은 콘텐츠 박스에만 - 데스크톱은 창==콘텐츠라 동일하고 OBS 소스에서는 남는 영역이 투명 */} + {/* 배경은 콘텐츠 박스에만 - 스프라이트 도달 여유와 OBS 소스의 남는 영역은 투명 */}
{ + beforeEach(() => { + // 프리뷰 구독은 편집 UI 창(main·panel)에서만 열린다 + window.__dmn_window_type = 'main'; + }); + afterEach(() => { editorCoordinator.stop(); + delete window.__dmn_window_type; }); it('recovers lazy subscription and maps committed gesture cleanup', async () => { diff --git a/src/renderer/editor/runtime/editorStateCoordinator.ts b/src/renderer/editor/runtime/editorStateCoordinator.ts index 842e6bec7..df11934d6 100644 --- a/src/renderer/editor/runtime/editorStateCoordinator.ts +++ b/src/renderer/editor/runtime/editorStateCoordinator.ts @@ -27,7 +27,14 @@ const hasChanged = (current: unknown, next: unknown) => let previewSubscribed = false; let previewSubscriptionInFlight: Promise | null = null; +// 프리뷰 봉투는 편집 UI가 있는 창(main·panel)끼리의 실시간 피드백 전용. +// 오버레이·OBS는 방송 화면이라 스크럽 중 덜덜거림 없이 커밋 시점만 반영한다 +const PREVIEW_WINDOW_TYPES = new Set(['main', 'panel']); + const ensurePreviewSubscription = (): Promise => { + if (!PREVIEW_WINDOW_TYPES.has(window.__dmn_window_type ?? '')) { + return Promise.resolve(); + } if (previewSubscribed) return Promise.resolve(); if (previewSubscriptionInFlight) return previewSubscriptionInFlight; diff --git a/src/renderer/editor/runtime/elementOps.ts b/src/renderer/editor/runtime/elementOps.ts index 39700079a..ed4f37fd3 100644 --- a/src/renderer/editor/runtime/elementOps.ts +++ b/src/renderer/editor/runtime/elementOps.ts @@ -16,6 +16,7 @@ import { import { newElementId } from '../model/elementId'; import { cloneSlot } from '@utils/keySlot'; import { reissueSpritePoseIds } from '@utils/sprite/poseIdentity'; +import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import { stableStringify } from '@utils/core/stableStringify'; import { normalizeLayerGroupsForMode, @@ -626,16 +627,17 @@ export const placeDuplicatedSprite = ( const cloned = structuredClone(source); return insertFrozenElement(mode, { elementType: 'sprite', - position: { + // wire 정규화: 원본의 명시 null layerName·groupId도 키 부재로 맞춘다 + position: toSpriteWireShape({ ...cloned, id: newElementId(), // 사본 poseId 재발급 - 원본과 공유하면 백엔드가 중복으로 거부 poses: reissueSpritePoseIds(cloned.poses), - groupId: groupForMode(mode, source.groupId ?? undefined) ?? null, + groupId: groupForMode(mode, source.groupId ?? undefined), dx, dy, zIndex, - }, + }), }); }; diff --git a/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts b/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts new file mode 100644 index 000000000..7e0d90e4b --- /dev/null +++ b/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + CanonicalReactiveSpritePosition, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorDocumentV1, +} from '@src/types/editor'; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + +// IPC 경계만 대역으로 세우고 itemsApi·coordinator·스토어 배선은 실제 그대로. +// 패널 커밋이 동결 소유권 검사에 걸려 낙관 적용을 건너뛰던 회귀를 잡는다 +const runtime = vi.hoisted(() => { + let resolveCommit!: (result: EditorCommitResult) => void; + const commit = vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolveCommit = resolve; + }), + ); + const get = vi.fn(); + const onCommitted = vi.fn((_listener: (event: EditorCommittedV1) => void) => + Object.assign(() => {}, { ready: Promise.resolve() }), + ); + const subscribe = vi.fn(async () => 1); + const cancel = vi.fn(async () => {}); + + return { + cancel, + commit, + get, + onCommitted, + resolveCommit: (result: EditorCommitResult) => resolveCommit(result), + subscribe, + }; +}); + +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); + +vi.mock('@api/modules/previewApi', () => ({ + previewApi: { + cancel: runtime.cancel, + subscribe: runtime.subscribe, + }, +})); + +// coordinator 싱글턴은 stop이 종결이라 케이스마다 모듈 그래프를 새로 얹는다 +const loadHarness = async () => { + const [itemsApi, spriteStore, wireShape, coordinator] = await Promise.all([ + import('@api/modules/itemsApi'), + import('@stores/data/useSpriteStore'), + import('@utils/sprite/spriteWireShape'), + import('./editorStateCoordinator'), + ]); + return { + spriteItemsApi: itemsApi.spriteItemsApi, + useSpriteStore: spriteStore.useSpriteStore, + toSpriteWireShape: wireShape.toSpriteWireShape, + editorCoordinator: coordinator.editorCoordinator, + }; +}; +type Harness = Awaited>; +let harness: Harness | null = null; + +// 백엔드 실물 wire 형태: layerName·groupId는 None이면 직렬화에서 생략된다 +const spriteFixture = (): CanonicalReactiveSpritePosition => ({ + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', +}); + +// 프론트 생성 리터럴 형태: 두 필드를 명시 null로 채운다 +const frontendCreationFixture = (): CanonicalReactiveSpritePosition => ({ + ...spriteFixture(), + layerName: null, + groupId: null, +}); + +const makeDocument = (): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: {}, + keyPositions: {}, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: { '4key': [spriteFixture()] }, + layerGroups: {}, +}); + +describe('스프라이트 필드 패치 낙관 적용 통합', () => { + beforeEach(async () => { + vi.resetModules(); + runtime.commit.mockClear(); + runtime.get.mockReset(); + runtime.get.mockResolvedValue({ revision: 0, document: makeDocument() }); + harness = await loadHarness(); + }); + + afterEach(() => { + harness?.editorCoordinator.stop(); + harness = null; + }); + + it('patchPosition 커밋은 응답 착지 전에 canonical 스토어에 반영된다', async () => { + const { spriteItemsApi, useSpriteStore } = harness!; + + const write = spriteItemsApi.patchPosition('4key', SPRITE_ID, { + baseImage: 'hand.png', + }); + + await vi.waitFor(() => expect(runtime.commit).toHaveBeenCalledOnce()); + // 낙관 로컬 적용: 백엔드 응답 전에 이미 canonical이 갱신된다 + expect(useSpriteStore.getState().positions['4key'][0]).toMatchObject({ + id: SPRITE_ID, + baseImage: 'hand.png', + }); + + // wire에는 최신 base 기반 전체 레코드가 실리고 두 키는 부재를 유지한다 + const request = runtime.commit.mock.calls[0][0]; + const wireRecord = request.changes?.spritePositions?.['4key']?.[0]; + expect(wireRecord).toMatchObject({ + id: SPRITE_ID, + baseImage: 'hand.png', + }); + expect('layerName' in wireRecord!).toBe(false); + expect('groupId' in wireRecord!).toBe(false); + + runtime.resolveCommit({ revision: 1, changedFields: ['spritePositions'] }); + await expect(write).resolves.toBe('committed'); + expect(useSpriteStore.getState().positions['4key'][0].baseImage).toBe( + 'hand.png', + ); + }); + + it('세션 생성 스프라이트도 정규화를 거치면 낙관 적용이 성립한다', async () => { + const { spriteItemsApi, useSpriteStore, toSpriteWireShape } = harness!; + const { editorCoordinator } = harness!; + + // ack는 백엔드 실물 wire 형태(두 키 부재), 스토어는 프론트 생성 경로 + // 형태(명시 null 리터럴이 wire 정규화를 거친 결과)로 시드한다. + // 정규화가 빠지면 스토어와 ack가 갈라져 소유권 검사가 적용을 스킵한다 + await editorCoordinator.start(); + useSpriteStore.setState({ + positions: { '4key': [toSpriteWireShape(frontendCreationFixture())] }, + }); + + const write = spriteItemsApi.patchPosition('4key', SPRITE_ID, { + baseImage: 'hand.png', + }); + + await vi.waitFor(() => expect(runtime.commit).toHaveBeenCalledOnce()); + const optimistic = useSpriteStore.getState().positions['4key'][0]; + expect(optimistic).toMatchObject({ id: SPRITE_ID, baseImage: 'hand.png' }); + expect('layerName' in optimistic).toBe(false); + expect('groupId' in optimistic).toBe(false); + + runtime.resolveCommit({ revision: 1, changedFields: ['spritePositions'] }); + await expect(write).resolves.toBe('committed'); + }); + + it('대상 소실이면 무커밋으로 targetMissing을 반환한다', async () => { + const { spriteItemsApi, useSpriteStore } = harness!; + + const write = spriteItemsApi.patchPosition('4key', 'missing-sprite-id', { + baseImage: 'hand.png', + }); + + await expect(write).resolves.toBe('targetMissing'); + expect(runtime.commit).not.toHaveBeenCalled(); + expect(useSpriteStore.getState().positions['4key'][0].baseImage).toBeNull(); + }); +}); diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx index 9688155c6..aa8fdac1c 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx +++ b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx @@ -207,8 +207,6 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { height: 200, hidden: false, zIndex: null, - layerName: null, - groupId: null, className: null, useInlineStyles: null, baseImage: null, @@ -221,6 +219,9 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { transitionMs: DEFAULT_SPRITE_TRANSITION_MS, transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, }); + // wire 정규화 계약: nullish layerName·groupId는 키 부재 + expect(payload).not.toHaveProperty('layerName'); + expect(payload).not.toHaveProperty('groupId'); }); it('key ghost place는 동결 slot과 payload를 index 재조회 없이 전달한다', () => { diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.ts b/src/renderer/hooks/Grid/useGridCanvasActions.ts index 84ccd8068..62a68fa16 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.ts +++ b/src/renderer/hooks/Grid/useGridCanvasActions.ts @@ -46,6 +46,7 @@ import { DEFAULT_ELEMENT_FONT, DEFAULT_ELEMENT_RADIUS, } from '@utils/core/elementDefaults'; +import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; // 공통: zIndex 목록 수집 - 결과가 persist되는 z 계산에 쓰이므로 canonical 기준 function collectAllZIndexes(mode: string) { @@ -492,28 +493,31 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { }; const addSpriteAtPosition = (dx: number, dy: number) => { - const position: ReactiveSpritePosition & { id: string } = { - id: newElementId(), - dx, - dy, - width: 200, - height: 200, - hidden: false, - zIndex: null, - layerName: null, - groupId: null, - className: null, - useInlineStyles: null, - baseImage: null, - imageFit: 'contain', - imageRect: { x: 0, y: 0, width: 200, height: 200 }, - pivot: { ...CENTER_SPRITE_ANCHOR }, - idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, - poses: [], - activation: 'whileHeld', - transitionMs: DEFAULT_SPRITE_TRANSITION_MS, - transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, - }; + // wire 정규화: nullish layerName·groupId는 키 부재로 맞춰 ack와 일치 + const position: ReactiveSpritePosition & { id: string } = toSpriteWireShape( + { + id: newElementId(), + dx, + dy, + width: 200, + height: 200, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: 'contain', + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: { ...CENTER_SPRITE_ANCHOR }, + idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, + poses: [], + activation: 'whileHeld', + transitionMs: DEFAULT_SPRITE_TRANSITION_MS, + transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, + }, + ); void addSpriteAt(selectedKeyType, position).catch(reportElementOpError); }; diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index 76c6d365f..66a344a27 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -499,6 +499,76 @@ describe('useGridSelection compound history gesture', () => { expect(spriteElement.position.poses).toEqual(pasted.poses); }); + it('키와 함께 붙여넣은 sprite 트리거는 사본 키의 새 id로 재결합된다', async () => { + let serial = 0; + randomUUID.mockImplementation( + () => `80000000-0000-4000-8000-${String(++serial).padStart(12, '0')}`, + ); + const externalKeyId = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + const original: CanonicalReactiveSpritePosition = { + ...spriteAt(STABLE_SPRITE_ID), + poses: [ + { + poseId: 'pose-1', + // 배치 안 키와 배치 밖 키 참조 혼합 + triggers: [STABLE_KEY_ID, externalKeyId], + matchMode: 'exact', + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + }; + act(() => { + useSpriteStore.setState({ positions: { '4key': [original] } }); + useGridSelectionStore.getState().setSelectedElements([ + { type: 'key', id: STABLE_KEY_ID, index: 0 }, + { type: 'sprite', id: STABLE_SPRITE_ID, index: 0 }, + ]); + }); + + act(() => api.copySelectedElements()); + await act(async () => api.pasteElements()); + + const pastedKey = useKeyStore + .getState() + .canonicalPositions['4key'].find( + (position) => position.id !== STABLE_KEY_ID, + )!; + const pastedSprite = useSpriteStore + .getState() + .positions['4key'].find((position) => position.id !== STABLE_SPRITE_ID)!; + // 같은 배치의 키는 사본 id로 재결합, 배치 밖 키 참조는 보존 + expect(pastedSprite.poses[0].triggers).toEqual([ + pastedKey.id, + externalKeyId, + ]); + // 원본 sprite의 트리거는 그대로 + expect( + useSpriteStore.getState().positions['4key'][0].poses[0].triggers, + ).toEqual([STABLE_KEY_ID, externalKeyId]); + }); + + it('sprite 단독 붙여넣기는 배치 밖 키를 가리키는 트리거를 유지한다', async () => { + const original = spriteAt(STABLE_SPRITE_ID); + act(() => { + useSpriteStore.setState({ positions: { '4key': [original] } }); + useGridSelectionStore + .getState() + .setSelectedElements([ + { type: 'sprite', id: STABLE_SPRITE_ID, index: 0 }, + ]); + }); + + act(() => api.copySelectedElements()); + await act(async () => api.pasteElements()); + + const pastedSprite = useSpriteStore + .getState() + .positions['4key'].find((position) => position.id !== STABLE_SPRITE_ID)!; + // 담당 키가 배치에 없으므로 원본 키 참조 유지 + expect(pastedSprite.poses[0].triggers).toEqual([STABLE_KEY_ID]); + }); + it('혼합 붙여넣기는 editor와 plugin에 같은 gestureId를 전달한다', async () => { act(() => { useGridSelectionStore.getState().setClipboard([ diff --git a/src/renderer/hooks/Grid/useGridSelection.ts b/src/renderer/hooks/Grid/useGridSelection.ts index d558f989a..e297a7271 100644 --- a/src/renderer/hooks/Grid/useGridSelection.ts +++ b/src/renderer/hooks/Grid/useGridSelection.ts @@ -22,7 +22,11 @@ import { import { PASTE_OFFSET } from './constants'; import type { KeyMappings, KeySlot } from '@src/types/key/keys'; import { cloneSlot } from '@utils/keySlot'; -import { reissueSpritePoseIds } from '@utils/sprite/poseIdentity'; +import { + reissueSpritePoseIds, + remapSpritePoseTriggers, +} from '@utils/sprite/poseIdentity'; +import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { buildNextLayerGroupName, @@ -733,6 +737,10 @@ export function useGridSelection({ : undefined; }; + // 배치 내 키 구id -> 신id 수집 - 함께 붙여넣는 스프라이트 트리거가 + // 원본 키 대신 사본 키를 가리키게 한다 (groupIdMap과 같은 패턴) + const keyIdMap = new Map(); + // 신규 native payload 동결 const keysToAdd: { keyCode: KeySlot; @@ -745,11 +753,13 @@ export function useGridSelection({ const pluginPayloads: Omit[] = []; for (const item of currentClipboard) { if (item.type === 'key') { + const newKeyId = newElementId(); + if (item.position.id) keyIdMap.set(item.position.id, newKeyId); keysToAdd.push({ keyCode: cloneSlot(item.keyCode), position: { ...item.position, - id: newElementId(), + id: newKeyId, groupId: remapGroupId(item.position.groupId), dx: (item.position.dx || 0) + PASTE_OFFSET, dy: (item.position.dy || 0) + PASTE_OFFSET, @@ -787,15 +797,16 @@ export function useGridSelection({ }); } else if (item.type === 'sprite') { spritesToAdd.push({ - position: { + // wire 정규화: nullish layerName·groupId는 키 부재로 맞춰 ack와 일치 + position: toSpriteWireShape({ ...item.position, id: newElementId(), // 사본 poseId 재발급 - 원본과 공유하면 백엔드가 중복으로 거부 poses: reissueSpritePoseIds(item.position.poses), - groupId: remapGroupId(item.position.groupId ?? undefined) ?? null, + groupId: remapGroupId(item.position.groupId ?? undefined), dx: (item.position.dx || 0) + PASTE_OFFSET, dy: (item.position.dy || 0) + PASTE_OFFSET, - }, + }), }); } else if (item.type === 'plugin') { pluginPayloads.push({ @@ -810,6 +821,17 @@ export function useGridSelection({ } } + // 스프라이트 트리거를 같은 배치 키의 신 id로 재결합, 배치 밖 키 참조는 + // 유지. 키가 스프라이트 뒤에 올 수 있어 payload 동결 후 일괄 치환한다 + if (keyIdMap.size > 0) { + for (const sprite of spritesToAdd) { + sprite.position.poses = remapSpritePoseTriggers( + sprite.position.poses, + keyIdMap, + ); + } + } + // maxInstances 사전 검증 - add 경로(생성 메뉴)와 동일하게 상한 도달 시 // 거부·경고. 혼합 선택도 destructive 관례(부분 성공 금지)에 따라 전체 중단 const frozenInstanceCaps: Array<{ diff --git a/src/renderer/hooks/shared/useLayoutComputation.test.ts b/src/renderer/hooks/shared/useLayoutComputation.test.ts new file mode 100644 index 000000000..b6e531e02 --- /dev/null +++ b/src/renderer/hooks/shared/useLayoutComputation.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_NOTE_SETTINGS } from '@constants/overlayDefaults'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { SpriteTransform } from '@src/types/key/sprites'; +import { DEFAULT_SPRITE_TRANSITION_EASING } from '@src/types/key/sprites'; + +import { computeLayout } from './useLayoutComputation'; + +const makeTransform = ( + overrides: Partial = {}, +): SpriteTransform => ({ + x: 0, + y: 0, + rotation: 0, + scale: 1, + ...overrides, +}); + +const makeSprite = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => ({ + id: 'sprite-1', + dx: 100, + dy: 50, + width: 200, + height: 200, + hidden: false, + zIndex: null, + layerName: null, + groupId: null, + className: null, + useInlineStyles: null, + baseImage: 'base.png', + imageFit: null, + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: makeTransform(), + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, + ...overrides, +}); + +const makeInput = (sprites: CanonicalReactiveSpritePosition[]) => ({ + currentKeys: [], + currentPositions: [], + currentStatPositions: [], + currentGraphPositions: [], + currentKnobPositions: [], + currentSpritePositions: sprites, + trackHeight: 150, + noteSettings: DEFAULT_NOTE_SETTINGS, + selectedKeyType: '4key', +}); + +// PADDING 30, trackHeight 150 기준 topOffset 180 +describe('computeLayout 스프라이트 오버행', () => { + it('오버행 없는 스프라이트는 활동 영역이 곧 창 바운즈다', () => { + const layout = computeLayout(makeInput([makeSprite()])); + expect(layout.bounds).toEqual({ + minX: 100, + minY: 50, + maxX: 300, + maxY: 250, + }); + expect(layout.backgroundBox).toEqual({ + x: 0, + y: 0, + width: 260, + height: 410, + }); + expect(layout.positionOffset).toEqual({ x: -70, y: 130 }); + expect(layout.topMostY).toBe(180); + }); + + it('45도 회전 오버행은 창 바운즈만 넓히고 배경 박스 크기는 그대로다', () => { + const layout = computeLayout( + makeInput([ + makeSprite({ idleTransform: makeTransform({ rotation: 45 }) }), + ]), + ); + // 대각선 오버행 41.42가 활동 영역 사방으로 붙는다 + expect(layout.bounds?.minX).toBeCloseTo(58.5786, 3); + expect(layout.bounds?.minY).toBeCloseTo(8.5786, 3); + expect(layout.bounds?.maxX).toBeCloseTo(341.4214, 3); + expect(layout.bounds?.maxY).toBeCloseTo(291.4214, 3); + + // 배경 박스는 콘텐츠 바운즈 기준 위치·크기 유지 + expect(layout.backgroundBox?.x).toBeCloseTo(41.4214, 3); + expect(layout.backgroundBox?.y).toBeCloseTo(41.4214, 3); + expect(layout.backgroundBox?.width).toBe(260); + expect(layout.backgroundBox?.height).toBe(410); + + // 아이템 오프셋은 창 바운즈 기준이라 콘텐츠가 여유만큼 안쪽으로 밀린다 + expect(layout.positionOffset.x).toBeCloseTo(30 - 58.5786, 3); + expect(layout.positionOffset.y).toBeCloseTo(180 - 8.5786, 3); + expect(layout.displaySpritePositions[0].dx).toBeCloseTo(71.4214, 3); + + // 트랙 시작은 콘텐츠 상단을 따라간다 + expect(layout.topMostY).toBeCloseTo(221.4214, 3); + }); + + it('이미지 없는 스프라이트는 회전해도 창을 넓히지 않는다', () => { + const layout = computeLayout( + makeInput([ + makeSprite({ + baseImage: null, + idleTransform: makeTransform({ rotation: 45 }), + }), + ]), + ); + expect(layout.bounds).toEqual({ + minX: 100, + minY: 50, + maxX: 300, + maxY: 250, + }); + expect(layout.backgroundBox).toEqual({ + x: 0, + y: 0, + width: 260, + height: 410, + }); + }); + + it('숨긴 스프라이트는 바운즈에 참여하지 않는다', () => { + const layout = computeLayout( + makeInput([ + makeSprite({ + hidden: true, + idleTransform: makeTransform({ rotation: 45 }), + }), + ]), + ); + expect(layout.bounds).toBeNull(); + expect(layout.backgroundBox).toBeNull(); + }); +}); diff --git a/src/renderer/hooks/shared/useLayoutComputation.ts b/src/renderer/hooks/shared/useLayoutComputation.ts index c25cba3f3..f2ad5b105 100644 --- a/src/renderer/hooks/shared/useLayoutComputation.ts +++ b/src/renderer/hooks/shared/useLayoutComputation.ts @@ -7,6 +7,7 @@ import type { NoteSettings } from '@src/types/settings/noteSettings'; // 레이아웃이 읽을 수 있는 플러그인 필드는 투영 타입으로 제한 — // 필드 추가 시 selectPluginLayoutElements·pluginLayoutElementsEqual 동반 수정 필요 import type { PluginLayoutElement } from '@utils/plugin/pluginLayoutElements'; +import { computeSpriteReachAabb } from '@utils/sprite/spriteReach'; interface LayoutInput { // canonical 슬롯 식별자 배열 (slotCanonical 결과, 원본 KeySlot 아님) @@ -51,8 +52,8 @@ export function computeLayout(input: LayoutInput) { overlayPadding: PADDING = 30, } = input; - // bounds 계산 - const bounds: Bounds | null = (() => { + // 콘텐츠 바운즈 계산 - 배경 박스가 덮는 영역의 기준 + const contentBounds: Bounds | null = (() => { const hasContent = currentPositions.length > 0 || currentStatPositions.length > 0 || @@ -126,7 +127,7 @@ export function computeLayout(input: LayoutInput) { heights.push(pos.dy + (pos.height ?? 80)); }); - // 스프라이트 활동 영역 전체를 창 크기에 포함, 클리핑하지 않는다 (계약 §9) + // 스프라이트 활동 영역 전체를 콘텐츠 바운즈에 포함 (계약 §9) currentSpritePositions.forEach((pos) => { if (!pos || pos.hidden) return; xs.push(pos.dx); @@ -182,11 +183,51 @@ export function computeLayout(input: LayoutInput) { }; })(); + // 창 바운즈 계산 - 스프라이트는 클리핑하지 않으므로 이미지 도달 범위 + // (모든 자세의 회전·확대·오프셋 AABB 합집합, 전환 오버슈트 여유 포함)가 + // 활동 영역을 넘으면 그만큼 창을 넓혀 네이티브 창 가장자리 잘림을 막는다. + // 배경 박스는 콘텐츠 바운즈 기준을 유지해 눈에 보이는 크기는 변하지 않는다 + const bounds: Bounds | null = (() => { + if (!contentBounds) return null; + let { minX, minY, maxX, maxY } = contentBounds; + currentSpritePositions.forEach((pos) => { + if (!pos || pos.hidden) return; + const reach = computeSpriteReachAabb(pos); + if (!reach) return; + minX = Math.min(minX, pos.dx + reach.minX); + minY = Math.min(minY, pos.dy + reach.minY); + maxX = Math.max(maxX, pos.dx + reach.maxX); + maxY = Math.max(maxY, pos.dy + reach.maxY); + }); + if ( + minX === contentBounds.minX && + minY === contentBounds.minY && + maxX === contentBounds.maxX && + maxY === contentBounds.maxY + ) { + return contentBounds; + } + return { minX, minY, maxX, maxY }; + })(); + // 오프셋 계산 const topOffset = trackHeight + PADDING; const offsetX = bounds ? PADDING - bounds.minX : 0; const offsetY = bounds ? topOffset - bounds.minY : 0; + // 배경 박스 - 콘텐츠 바운즈 + 패딩, 창 좌표 기준. + // 스프라이트 도달 여유로 창 원점이 왼쪽·위로 밀리면 x·y가 그만큼 커져 + // 배경의 화면상 위치·크기는 오버행 유무와 무관하게 동일하다 + const backgroundBox = + bounds && contentBounds + ? { + x: contentBounds.minX - bounds.minX, + y: contentBounds.minY - bounds.minY, + width: contentBounds.maxX - contentBounds.minX + PADDING * 2, + height: contentBounds.maxY - contentBounds.minY + PADDING + topOffset, + } + : null; + // 원본 객체와 오프셋이 그대로면 이전 결과를 재사용한다. // 매번 새 객체를 만들면 아래쪽 Key의 React.memo가 항상 깨져, // 프리뷰로 키 하나만 움직여도 오버레이의 모든 키가 다시 그려진다 @@ -217,7 +258,8 @@ export function computeLayout(input: LayoutInput) { const positionOffset = bounds ? { x: offsetX, y: offsetY } : { x: 0, y: 0 }; - const topMostY = bounds ? topOffset : 0; + // 콘텐츠 상단의 창 좌표. 스프라이트 오버행이 위로 없으면 topOffset과 같다 + const topMostY = contentBounds ? contentBounds.minY + offsetY : 0; // WebGL 트랙 계산 const webglTracks = currentKeys @@ -281,14 +323,17 @@ export function computeLayout(input: LayoutInput) { .filter(Boolean); return { + // 창 크기 계산이 쓰는 창 바운즈 (콘텐츠 + 스프라이트 이미지 도달 범위) bounds, + // 배경이 덮는 박스 - 창 좌표 기준, 오버행 여유는 배경 밖 투명으로 남는다 + backgroundBox, displayPositions, displayStatPositions, displayGraphPositions, displayKnobPositions, displaySpritePositions, positionOffset, - // 창 높이·배경 박스가 같은 값을 쓰도록 노출 (창 == 콘텐츠 박스 불변식) + // 창 높이·배경 박스가 같은 값을 쓰도록 노출 topOffset, topMostY, webglTracks, diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 64b83e733..b73c8fe49 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -223,6 +223,7 @@ "ok": "OK", "save": "Save", "more": "More", + "copySuffix": "Copy", "dialog": "Dialog" }, "numberInput": { @@ -328,6 +329,7 @@ }, "contextMenu": { "delete": "Delete", + "duplicate": "Duplicate", "duplicateKey": "Duplicate Key", "deleteKey": "Delete Key", "duplicateSelected": "Duplicate Selection", @@ -553,23 +555,22 @@ "spriteBaseImage": "Base Image", "spriteImageSelect": "Select", "spriteImageRemove": "Remove", - "spriteImageRect": "Image Position", - "spriteImageSize": "Image Size", + "spriteImageRect": "Position", + "spriteImageSize": "Size", "spritePivot": "Pivot", - "spriteIdlePose": "Idle Pose", "spriteRotation": "Rotation", "spriteScale": "Scale", - "spritePose": "Pose", - "spriteAddPose": "Add Pose", - "spriteRemovePose": "Delete Pose", + "spritePose": "State", + "spriteAddPose": "Add State", "spriteTriggerKeys": "Trigger Keys", - "spriteImageOverride": "Pose Image", + "spriteTriggerPlaceholder": "Select keys", + "spriteImageOverride": "State Image", "spriteMissingKey": "Deleted key", "spriteNoKeys": "No key elements in this mode", - "spriteEmptyTriggers": "Select trigger keys to save this pose", - "spriteDuplicateTriggers": "A pose with the same key set already exists", + "spriteDuplicateTriggers": "Duplicate key combination", + "spriteDuplicateShort": "Duplicate", "spriteTransition": "Transition", - "spriteEasing": "Easing", + "spriteEasing": "Motion", "spriteEasingCustom": "Custom", "graphSpeed": "Graph Speed", "graphColor": "Graph Color", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 1a8b28c20..f40f4feaf 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -223,6 +223,7 @@ "ok": "확인", "save": "저장", "more": "더보기", + "copySuffix": "복제", "dialog": "대화상자" }, "numberInput": { @@ -328,6 +329,7 @@ }, "contextMenu": { "delete": "삭제", + "duplicate": "복제", "duplicateKey": "키 복제", "deleteKey": "키 삭제", "duplicateSelected": "선택 항목 복제", @@ -553,23 +555,22 @@ "spriteBaseImage": "기본 이미지", "spriteImageSelect": "선택", "spriteImageRemove": "제거", - "spriteImageRect": "이미지 위치", - "spriteImageSize": "이미지 크기", + "spriteImageRect": "위치", + "spriteImageSize": "크기", "spritePivot": "기준점", - "spriteIdlePose": "대기 자세", "spriteRotation": "회전", "spriteScale": "배율", - "spritePose": "자세", - "spriteAddPose": "자세 추가", - "spriteRemovePose": "자세 삭제", + "spritePose": "상태", + "spriteAddPose": "상태 추가", "spriteTriggerKeys": "담당 키", - "spriteImageOverride": "자세 이미지", + "spriteTriggerPlaceholder": "키 선택", + "spriteImageOverride": "상태 이미지", "spriteMissingKey": "삭제된 키", "spriteNoKeys": "이 모드에 키 요소가 없습니다", - "spriteEmptyTriggers": "담당 키를 선택하면 저장됩니다", - "spriteDuplicateTriggers": "같은 키 조합의 자세가 있어 저장되지 않습니다", + "spriteDuplicateTriggers": "중복된 키 조합입니다", + "spriteDuplicateShort": "중복 키", "spriteTransition": "전환 시간", - "spriteEasing": "가속 곡선", + "spriteEasing": "모션", "spriteEasingCustom": "사용자 정의", "graphSpeed": "그래프 속도", "graphColor": "그래프 색상", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 59c8866ad..b277de281 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -223,6 +223,7 @@ "ok": "ОК", "save": "Сохранить", "more": "Ещё", + "copySuffix": "копия", "dialog": "Диалог" }, "numberInput": { @@ -328,6 +329,7 @@ }, "contextMenu": { "delete": "Удалить", + "duplicate": "Дублировать", "duplicateKey": "Дублировать клавишу", "deleteKey": "Удалить клавишу", "duplicateSelected": "Дублировать выбранное", @@ -558,23 +560,22 @@ "spriteBaseImage": "Базовая картинка", "spriteImageSelect": "Выбрать", "spriteImageRemove": "Убрать", - "spriteImageRect": "Позиция картинки", - "spriteImageSize": "Размер картинки", + "spriteImageRect": "Позиция", + "spriteImageSize": "Размер", "spritePivot": "Точка опоры", - "spriteIdlePose": "Поза покоя", "spriteRotation": "Поворот", "spriteScale": "Масштаб", - "spritePose": "Поза", - "spriteAddPose": "Добавить позу", - "spriteRemovePose": "Удалить позу", + "spritePose": "Состояние", + "spriteAddPose": "Добавить состояние", "spriteTriggerKeys": "Клавиши позы", - "spriteImageOverride": "Картинка позы", + "spriteTriggerPlaceholder": "Выберите клавиши", + "spriteImageOverride": "Картинка состояния", "spriteMissingKey": "Удалённая клавиша", "spriteNoKeys": "В этом режиме нет клавиш", - "spriteEmptyTriggers": "Выберите клавиши, чтобы сохранить позу", - "spriteDuplicateTriggers": "Поза с таким набором клавиш уже есть", + "spriteDuplicateTriggers": "Такая комбинация клавиш уже есть", + "spriteDuplicateShort": "Дубликат", "spriteTransition": "Переход", - "spriteEasing": "Сглаживание", + "spriteEasing": "Моушн", "spriteEasingCustom": "Настраиваемый", "customImage": "Своя картинка", "configure": "Настроить", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 6abab22c5..a5735377e 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -223,6 +223,7 @@ "ok": "確定", "save": "儲存", "more": "更多", + "copySuffix": "拷貝", "dialog": "對話框" }, "numberInput": { @@ -328,6 +329,7 @@ }, "contextMenu": { "delete": "刪除", + "duplicate": "複製", "duplicateKey": "複製按鍵", "deleteKey": "刪除按鍵", "duplicateSelected": "複製所選項目", @@ -558,23 +560,22 @@ "spriteBaseImage": "基礎圖像", "spriteImageSelect": "選擇", "spriteImageRemove": "移除", - "spriteImageRect": "圖像位置", - "spriteImageSize": "圖像大小", + "spriteImageRect": "位置", + "spriteImageSize": "大小", "spritePivot": "基準點", - "spriteIdlePose": "待機姿勢", "spriteRotation": "旋轉", "spriteScale": "縮放", - "spritePose": "姿勢", - "spriteAddPose": "新增姿勢", - "spriteRemovePose": "刪除姿勢", + "spritePose": "狀態", + "spriteAddPose": "新增狀態", "spriteTriggerKeys": "觸發按鍵", - "spriteImageOverride": "姿勢圖像", + "spriteTriggerPlaceholder": "選擇按鍵", + "spriteImageOverride": "狀態圖像", "spriteMissingKey": "已刪除的按鍵", "spriteNoKeys": "此模式沒有按鍵元素", - "spriteEmptyTriggers": "選擇觸發按鍵後才會儲存", - "spriteDuplicateTriggers": "已存在相同按鍵組合的姿勢", + "spriteDuplicateTriggers": "按鍵組合重複", + "spriteDuplicateShort": "重複按鍵", "spriteTransition": "過渡時間", - "spriteEasing": "緩動曲線", + "spriteEasing": "動態", "spriteEasingCustom": "自訂", "customImage": "自定義圖像", "configure": "配置", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index da2f4ca9c..95afd7d3d 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -223,6 +223,7 @@ "ok": "确定", "save": "保存", "more": "更多", + "copySuffix": "拷贝", "dialog": "对话框" }, "numberInput": { @@ -328,6 +329,7 @@ }, "contextMenu": { "delete": "删除", + "duplicate": "复制", "duplicateKey": "复制按键", "deleteKey": "删除按键", "duplicateSelected": "复制选中项", @@ -558,23 +560,22 @@ "spriteBaseImage": "基础图像", "spriteImageSelect": "选择", "spriteImageRemove": "移除", - "spriteImageRect": "图像位置", - "spriteImageSize": "图像大小", + "spriteImageRect": "位置", + "spriteImageSize": "大小", "spritePivot": "基准点", - "spriteIdlePose": "待机姿势", "spriteRotation": "旋转", "spriteScale": "缩放", - "spritePose": "姿势", - "spriteAddPose": "添加姿势", - "spriteRemovePose": "删除姿势", + "spritePose": "状态", + "spriteAddPose": "添加状态", "spriteTriggerKeys": "触发按键", - "spriteImageOverride": "姿势图像", + "spriteTriggerPlaceholder": "选择按键", + "spriteImageOverride": "状态图像", "spriteMissingKey": "已删除的按键", "spriteNoKeys": "此模式没有按键元素", - "spriteEmptyTriggers": "选择触发按键后才会保存", - "spriteDuplicateTriggers": "已存在相同按键组合的姿势", + "spriteDuplicateTriggers": "按键组合重复", + "spriteDuplicateShort": "重复按键", "spriteTransition": "过渡时间", - "spriteEasing": "缓动曲线", + "spriteEasing": "动效", "spriteEasingCustom": "自定义", "customImage": "自定义图像", "configure": "配置", diff --git a/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts new file mode 100644 index 000000000..fbc3e8326 --- /dev/null +++ b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useSpriteEditPreviewStore } from './useSpriteEditPreviewStore'; +import type { SpritePose } from '@src/types/key/sprites'; + +const pose = (poseId: string): SpritePose => ({ + poseId, + triggers: ['k1'], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, +}); + +describe('useSpriteEditPreviewStore', () => { + afterEach(() => { + useSpriteEditPreviewStore.setState({ preview: null }); + }); + + it('같은 내용 재발행과 빈 상태 clear는 리스너를 깨우지 않는다', () => { + const listener = vi.fn(); + const unsubscribe = useSpriteEditPreviewStore.subscribe(listener); + const fallbackPose = pose('a'); + const payload = { + kind: 'pose' as const, + positionId: 'sprite-1', + poseId: 'a', + fallbackPose, + preferFallback: false, + }; + + useSpriteEditPreviewStore.getState().publish(payload); + expect(listener).toHaveBeenCalledTimes(1); + // 패널 리렌더마다 같은 내용이 다시 발행되는 경로 - set 자체가 없어야 한다 + useSpriteEditPreviewStore.getState().publish({ ...payload }); + expect(listener).toHaveBeenCalledTimes(1); + + useSpriteEditPreviewStore + .getState() + .publish({ kind: 'pivot', positionId: 'sprite-1' }); + expect(listener).toHaveBeenCalledTimes(2); + useSpriteEditPreviewStore + .getState() + .publish({ kind: 'pivot', positionId: 'sprite-1' }); + expect(listener).toHaveBeenCalledTimes(2); + + useSpriteEditPreviewStore.getState().clear(); + expect(listener).toHaveBeenCalledTimes(3); + useSpriteEditPreviewStore.getState().clear(); + expect(listener).toHaveBeenCalledTimes(3); + unsubscribe(); + }); + + it('자세 전환·preferFallback·종류 변경은 발행을 갱신한다', () => { + const store = useSpriteEditPreviewStore.getState(); + store.publish({ + kind: 'pose', + positionId: 'sprite-1', + poseId: 'a', + fallbackPose: pose('a'), + preferFallback: false, + }); + store.publish({ + kind: 'pose', + positionId: 'sprite-1', + poseId: 'b', + fallbackPose: pose('b'), + preferFallback: false, + }); + let preview = useSpriteEditPreviewStore.getState().preview; + expect(preview?.kind === 'pose' && preview.poseId).toBe('b'); + + store.publish({ + kind: 'pose', + positionId: 'sprite-1', + poseId: 'b', + fallbackPose: pose('b'), + preferFallback: true, + }); + preview = useSpriteEditPreviewStore.getState().preview; + expect(preview?.kind === 'pose' && preview.preferFallback).toBe(true); + + store.publish({ kind: 'pivot', positionId: 'sprite-1' }); + expect(useSpriteEditPreviewStore.getState().preview?.kind).toBe('pivot'); + }); +}); diff --git a/src/renderer/stores/grid/useSpriteEditPreviewStore.ts b/src/renderer/stores/grid/useSpriteEditPreviewStore.ts new file mode 100644 index 000000000..f5d0d3067 --- /dev/null +++ b/src/renderer/stores/grid/useSpriteEditPreviewStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; +import type { SpritePose } from '@src/types/key/sprites'; + +/** + * 스프라이트 편집 캔버스 프리뷰 - 패널이 편집 중인 것을 캔버스가 보조 표시하게 + * 공유한다. 자세 팝업은 그 자세의 렌더, 기준점 편집은 축 마커. + * 편집창(그리드) 전용이고 오버레이는 커밋만 반영한다. + * 발행자는 속성 패널 하나뿐이라 스택 없이 단일 값으로 충분 + */ + +export type SpriteEditPreview = + | { + kind: 'pose'; + positionId: string; + poseId: string; + /** composed poses에 없거나(신규 draft) 커밋 불가 상태일 때 그릴 스냅샷 */ + fallbackPose: SpritePose; + /** 무효 draft 편집 중 - composed의 옛 canonical 값 대신 스냅샷을 우선한다 */ + preferFallback: boolean; + } + | { kind: 'pivot'; positionId: string }; + +const previewEquals = (a: SpriteEditPreview, b: SpriteEditPreview): boolean => { + if (a.kind !== b.kind || a.positionId !== b.positionId) return false; + if (a.kind === 'pivot') return true; + return ( + b.kind === 'pose' && + a.poseId === b.poseId && + a.fallbackPose === b.fallbackPose && + a.preferFallback === b.preferFallback + ); +}; + +interface SpriteEditPreviewStore { + preview: SpriteEditPreview | null; + publish: (next: SpriteEditPreview) => void; + clear: () => void; +} + +export const useSpriteEditPreviewStore = create( + // 내용이 같으면 set 자체를 건너뛴다 - zustand는 빈 부분 갱신도 리스너를 깨운다 + (set, get) => ({ + preview: null, + publish: (next) => { + const prev = get().preview; + if (prev && previewEquals(prev, next)) return; + set({ preview: next }); + }, + clear: () => { + if (get().preview === null) return; + set({ preview: null }); + }, + }), +); + +/** + * 캔버스 leaf 소비 - 자기 스프라이트의 발행일 때만 프리뷰를 반환해 + * 다른 요소의 발행·회수에 리렌더되지 않는다 + */ +export function useSpriteEditPreview( + positionId: string, +): SpriteEditPreview | null { + return useSpriteEditPreviewStore((store) => + store.preview && store.preview.positionId === positionId + ? store.preview + : null, + ); +} diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index e42559b03..ca831776c 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -253,6 +253,19 @@ input[type='number']::-webkit-outer-spin-button { border-radius: inherit; } +/* + * 스프라이트 이미지 - 외관 채널은 변수 소비, 배치·기준점은 인라인 유지. + * 사용자 CSS는 공개 --sprite-* 변수나 일반 선언으로 언제든 덮어쓴다 + */ +:where([data-sprite-element] > img) { + object-fit: var(--sprite-fit, var(--dmn-sprite-fit-default, contain)); + transform: var(--sprite-transform, var(--dmn-sprite-transform-default, none)); + transition: var( + --sprite-transition, + var(--dmn-sprite-transition-default, none) + ); +} + /* 투명 보더 아래로 사용자 배경이 비치지 않게 하되 커스텀 CSS가 항상 우선 */ :where([data-key-element], [data-graph-element], [data-knob-element]) { background-clip: padding-box; diff --git a/src/renderer/utils/cardRecipes.ts b/src/renderer/utils/cardRecipes.ts index 3dc68bfce..4fed0b00e 100644 --- a/src/renderer/utils/cardRecipes.ts +++ b/src/renderer/utils/cardRecipes.ts @@ -19,6 +19,10 @@ export const FORM_ROW_CLASS = // 행 라벨 export const FORM_LABEL_CLASS = 'text-fg-muted text-label'; +// 카드 행 안 액션 버튼(선택·제거·설정) 크롬 +export const ACTION_BUTTON_CLASS = + 'px-[8px] h-[23px] bg-fill hover:bg-fill-hover active:bg-fill-active transition-colors duration-fast rounded-md flex items-center justify-center text-fg text-body'; + // 좌표·치수 필드 폭 - X/Y/W/H는 한 행에 둘씩 서고 소수 한 자리가 붙어도 잘리면 안 된다. // 패널 240px에서 카드 안쪽이 196px이라 두 칸(60+8+60)을 쓰고 라벨에 68px가 남는다. // 최장 라벨인 러시아어 Смещение, 영어 Position이 들어가는 폭 diff --git a/src/renderer/utils/layerGroupUtils.ts b/src/renderer/utils/layerGroupUtils.ts index facfd0a66..216ee57e7 100644 --- a/src/renderer/utils/layerGroupUtils.ts +++ b/src/renderer/utils/layerGroupUtils.ts @@ -7,6 +7,7 @@ import type { } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { normalizePluginInstanceTabId } from '@plugins/runtime/displayElement/instanceLifecycle'; +import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; export const isPluginVisibleInMode = ( element: Pick, @@ -249,9 +250,13 @@ export function applyGroupIdToSelectedElements(params: { const index = nextSpriteMode.findIndex(({ id }) => id === element.id); const current = nextSpriteMode[index]; - // 스프라이트 groupId는 null 저장이라 undefined 목표와 동치 비교 + // 명시 null 이력까지 undefined로 접어 동치 비교 if (!current || (current.groupId ?? undefined) === targetGroupId) return; - nextSpriteMode[index] = { ...current, groupId: targetGroupId ?? null }; + // wire 정규화: 해제(undefined)는 groupId 키 부재로 맞춰 ack와 일치 + nextSpriteMode[index] = toSpriteWireShape({ + ...current, + groupId: targetGroupId, + }); spriteChanged = true; changed = true; }); @@ -502,7 +507,8 @@ export function normalizeLayerGroupsForMode(params: { }); nextSpriteMode.forEach((pos, index) => { if (!shouldClear(pos?.groupId)) return; - nextSpriteMode[index] = { ...pos, groupId: null }; + // wire 정규화: 해제는 groupId 키 부재로 맞춰 ack와 일치 + nextSpriteMode[index] = toSpriteWireShape({ ...pos, groupId: undefined }); positionsChanged = true; }); } diff --git a/src/renderer/utils/sprite/poseIdentity.ts b/src/renderer/utils/sprite/poseIdentity.ts index b18f28e69..8f95f9b57 100644 --- a/src/renderer/utils/sprite/poseIdentity.ts +++ b/src/renderer/utils/sprite/poseIdentity.ts @@ -6,3 +6,14 @@ export const reissueSpritePoseIds = ( poses: readonly SpritePose[], ): SpritePose[] => poses.map((pose) => ({ ...pose, poseId: crypto.randomUUID() })); + +// 배치 복제 사본의 트리거 재결합. 같은 배치에서 함께 복제된 키의 신 id로 +// 치환하고, 배치 밖 키를 가리키는 트리거는 그대로 유지 +export const remapSpritePoseTriggers = ( + poses: readonly SpritePose[], + keyIdMap: ReadonlyMap, +): SpritePose[] => + poses.map((pose) => ({ + ...pose, + triggers: pose.triggers.map((id) => keyIdMap.get(id) ?? id), + })); diff --git a/src/renderer/utils/sprite/spriteImageStyles.ts b/src/renderer/utils/sprite/spriteImageStyles.ts new file mode 100644 index 000000000..2b62e5e30 --- /dev/null +++ b/src/renderer/utils/sprite/spriteImageStyles.ts @@ -0,0 +1,48 @@ +import type { CSSProperties } from 'react'; +import { + spriteTransformToCss, + type ReactiveSpritePosition, + type SpriteTransform, +} from '@src/types/key/sprites'; + +// 외관 채널 계산에 필요한 최소 필드 +type SpriteImageStyleSource = Pick< + ReactiveSpritePosition, + 'imageRect' | 'pivot' | 'imageFit' | 'useInlineStyles' +>; + +// 스프라이트 이미지 스타일 계산 - 오버레이·에디터 공용. +// 배치·기준점은 항상 인라인, 외관 채널(object-fit·transform·transition)은 +// 기본 모드에서 CSS 변수로 실어 전역 :where 규칙이 소비한다. 사용자 CSS가 +// !important 없이 이기고 자세 transition도 유지 (키 이미지 레이어와 동일 구조). +// 인라인 우선 모드(useInlineStyles=true)만 실제 선언으로 승격 +export const computeSpriteImageStyle = ( + position: SpriteImageStyleSource, + transform: SpriteTransform, + transition?: string, +): CSSProperties => { + const useInline = position.useInlineStyles === true; + const fit = position.imageFit ?? 'contain'; + const transformCss = spriteTransformToCss(transform); + return { + position: 'absolute', + left: `${position.imageRect.x}px`, + top: `${position.imageRect.y}px`, + width: `${position.imageRect.width}px`, + height: `${position.imageRect.height}px`, + transformOrigin: `${position.pivot.x * 100}% ${position.pivot.y * 100}%`, + ...(useInline + ? { + objectFit: fit as CSSProperties['objectFit'], + transform: transformCss, + ...(transition ? { transition } : {}), + } + : ({ + '--dmn-sprite-fit-default': fit, + '--dmn-sprite-transform-default': transformCss, + ...(transition + ? { '--dmn-sprite-transition-default': transition } + : {}), + } as CSSProperties)), + }; +}; diff --git a/src/renderer/utils/sprite/spriteReach.test.ts b/src/renderer/utils/sprite/spriteReach.test.ts new file mode 100644 index 000000000..f906305f8 --- /dev/null +++ b/src/renderer/utils/sprite/spriteReach.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; + +import type { SpritePose, SpriteTransform } from '@src/types/key/sprites'; +import { DEFAULT_SPRITE_TRANSITION_EASING } from '@src/types/key/sprites'; + +import { + computeSpriteReachAabb, + easingOutputRange, + easingOvershootExtension, + resolveSpriteRenderEasing, + SPRITE_SAFE_FALLBACK_EASING, + type SpriteReachGeometry, +} from './spriteReach'; + +const OVERSHOOT_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; + +const makeTransform = ( + overrides: Partial = {}, +): SpriteTransform => ({ + x: 0, + y: 0, + rotation: 0, + scale: 1, + ...overrides, +}); + +const makePose = ( + poseId: string, + transform: Partial = {}, + imageOverride: string | null = null, +): SpritePose => ({ + poseId, + triggers: [poseId], + matchMode: 'exact', + transform: makeTransform(transform), + imageOverride, +}); + +// 활동 영역 200x200에 imageRect가 영역 전체, pivot 중앙 (기본 생성값 형태) +const makeSprite = ( + overrides: Partial = {}, +): SpriteReachGeometry => ({ + baseImage: 'base.png', + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: makeTransform(), + poses: [], + transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, + ...overrides, +}); + +describe('computeSpriteReachAabb', () => { + it('변환 없는 스프라이트는 imageRect 그대로', () => { + const reach = computeSpriteReachAabb(makeSprite()); + expect(reach).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 }); + }); + + it('45도 회전은 대각선 오버행을 만든다', () => { + const reach = computeSpriteReachAabb( + makeSprite({ idleTransform: makeTransform({ rotation: 45 }) }), + ); + // pivot 중앙 기준 반대각 100*sqrt(2) + expect(reach?.minX).toBeCloseTo(-41.4214, 3); + expect(reach?.minY).toBeCloseTo(-41.4214, 3); + expect(reach?.maxX).toBeCloseTo(241.4214, 3); + expect(reach?.maxY).toBeCloseTo(241.4214, 3); + }); + + it('회전 방향은 CSS rotate와 같다 (y축 아래, 양수 시계방향)', () => { + const reach = computeSpriteReachAabb( + makeSprite({ + pivot: { x: 0, y: 0 }, + idleTransform: makeTransform({ rotation: 90 }), + }), + ); + expect(reach?.minX).toBeCloseTo(-200, 6); + expect(reach?.maxX).toBeCloseTo(0, 6); + expect(reach?.minY).toBeCloseTo(0, 6); + expect(reach?.maxY).toBeCloseTo(200, 6); + }); + + it('offset 이동은 AABB를 그대로 평행 이동한다', () => { + const reach = computeSpriteReachAabb( + makeSprite({ idleTransform: makeTransform({ x: 300, y: -50 }) }), + ); + expect(reach).toEqual({ minX: 300, minY: -50, maxX: 500, maxY: 150 }); + }); + + it('scale 확대는 pivot 기준으로 사방으로 커진다', () => { + const reach = computeSpriteReachAabb( + makeSprite({ idleTransform: makeTransform({ scale: 2 }) }), + ); + expect(reach).toEqual({ minX: -100, minY: -100, maxX: 300, maxY: 300 }); + }); + + it('상태 간 회전이 다르면 최원점 반경의 원을 상한으로 잡는다', () => { + const reach = computeSpriteReachAabb( + makeSprite({ poses: [makePose('p1', { rotation: 90 })] }), + ); + // 중간 각(예: 45도)의 대각선까지 커버해야 한다 + expect(reach?.minX).toBeCloseTo(-41.4214, 3); + expect(reach?.maxX).toBeCloseTo(241.4214, 3); + expect(reach?.minY).toBeCloseTo(-41.4214, 3); + expect(reach?.maxY).toBeCloseTo(241.4214, 3); + }); + + it('회전 상이 + offset 상이는 각 offset 위치의 원 합집합이다', () => { + const reach = computeSpriteReachAabb( + makeSprite({ poses: [makePose('p1', { x: 500, rotation: 90 })] }), + ); + expect(reach?.minX).toBeCloseTo(-41.4214, 3); + expect(reach?.maxX).toBeCloseTo(741.4214, 3); + }); + + it('오버슈트 easing은 상태 범위 밖 여유를 더한다', () => { + const noOvershoot = computeSpriteReachAabb( + makeSprite({ poses: [makePose('p1', { x: 100 })] }), + ); + expect(noOvershoot?.minX).toBeCloseTo(0, 6); + expect(noOvershoot?.maxX).toBeCloseTo(300, 6); + + const withOvershoot = computeSpriteReachAabb( + makeSprite({ + poses: [makePose('p1', { x: 100 })], + transitionEasing: OVERSHOOT_EASING, + }), + ); + // e = 0.0978, 재타깃 상한 e/(1-e) = 0.1084, 여유 = 0.1084 * 100 + expect(withOvershoot?.minX).toBeCloseTo(-10.84, 1); + expect(withOvershoot?.maxX).toBeCloseTo(310.84, 1); + }); + + it('렌더 가능한 이미지가 없으면 null', () => { + expect(computeSpriteReachAabb(makeSprite({ baseImage: null }))).toBeNull(); + }); + + it('pose imageOverride만 있어도 도달 범위를 계산한다', () => { + const reach = computeSpriteReachAabb( + makeSprite({ + baseImage: null, + poses: [makePose('p1', { x: 100 }, 'override.png')], + }), + ); + expect(reach?.maxX).toBeCloseTo(300, 6); + }); +}); + +describe('easingOutputRange', () => { + it('기본 easing과 키워드는 0~1을 벗어나지 않는다', () => { + expect(easingOutputRange(DEFAULT_SPRITE_TRANSITION_EASING)).toEqual({ + min: 0, + max: 1, + }); + expect(easingOutputRange('linear')).toEqual({ min: 0, max: 1 }); + expect(easingOutputRange('ease-in-out')).toEqual({ min: 0, max: 1 }); + }); + + it('오버슈트 cubic-bezier의 최대 출력을 정확히 구한다', () => { + expect(easingOutputRange(OVERSHOOT_EASING).max).toBeCloseTo(1.0978, 3); + expect(easingOutputRange(OVERSHOOT_EASING).min).toBe(0); + }); + + it('linear() 함수는 정지점 최소·최대를 쓴다', () => { + expect(easingOutputRange('linear(0, 1.5, 1)')).toEqual({ + min: 0, + max: 1.5, + }); + expect(easingOutputRange('linear(-0.2, 0.5 50%, 1)').min).toBe(-0.2); + }); + + it('해석 불가 문자열은 스냅 전환이라 0~1로 본다', () => { + expect(easingOutputRange('not-an-easing')).toEqual({ min: 0, max: 1 }); + // x 제어점이 0~1 밖이면 CSS 선언 자체가 무효 + expect(easingOutputRange('cubic-bezier(2, 5, 0.5, 1)')).toEqual({ + min: 0, + max: 1, + }); + }); +}); + +describe('easingOvershootExtension', () => { + it('오버슈트 없는 easing은 0', () => { + expect(easingOvershootExtension(DEFAULT_SPRITE_TRANSITION_EASING)).toBe(0); + expect(easingOvershootExtension('linear')).toBe(0); + }); + + it('재타깃 누적 상한 e/(1-e)를 적용한다', () => { + expect(easingOvershootExtension(OVERSHOOT_EASING)).toBeCloseTo(0.1084, 3); + expect(easingOvershootExtension('linear(0, 1.5, 1)')).toBeCloseTo(1, 6); + }); + + it('지나침 폭 1 이상은 폴백 곡선으로 강등되어 여유 0', () => { + expect(resolveSpriteRenderEasing('cubic-bezier(0.5, 10, 0.5, -9)')).toBe( + SPRITE_SAFE_FALLBACK_EASING, + ); + expect(easingOvershootExtension('cubic-bezier(0.5, 10, 0.5, -9)')).toBe(0); + expect(easingOvershootExtension('linear(0, 2.5, 1)')).toBe(0); + }); + + it('지나침 폭 1 미만 easing은 강등 없이 유지된다', () => { + expect(resolveSpriteRenderEasing(OVERSHOOT_EASING)).toBe(OVERSHOOT_EASING); + expect(resolveSpriteRenderEasing('linear')).toBe('linear'); + }); +}); diff --git a/src/renderer/utils/sprite/spriteReach.ts b/src/renderer/utils/sprite/spriteReach.ts new file mode 100644 index 000000000..0c92cbb3a --- /dev/null +++ b/src/renderer/utils/sprite/spriteReach.ts @@ -0,0 +1,234 @@ +import type { + ReactiveSpritePosition, + SpriteTransform, +} from '@src/types/key/sprites'; + +// 스프라이트 이미지 도달 범위 계산. +// 저장된 모든 상태(idle + pose)의 imageRect에 pivot 기준 scale·rotation·offset을 +// 적용한 AABB의 합집합이다. 창 바운즈가 이 값을 포함해야 이미지가 활동 영역을 +// 넘어도 네이티브 창 가장자리에서 잘리지 않는다. +// 반환 좌표계는 활동 영역(dx/dy) 로컬 기준 + +export interface SpriteAabb { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export type SpriteReachGeometry = Pick< + ReactiveSpritePosition, + | 'baseImage' + | 'imageRect' + | 'pivot' + | 'idleTransform' + | 'poses' + | 'transitionEasing' +>; + +const CSS_NUMBER = String.raw`[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`; + +const CUBIC_BEZIER_RE = new RegExp( + String.raw`^cubic-bezier\(\s*(${CSS_NUMBER})\s*,\s*(${CSS_NUMBER})\s*,\s*(${CSS_NUMBER})\s*,\s*(${CSS_NUMBER})\s*\)$`, + 'i', +); + +const LINEAR_FN_RE = /^linear\((.+)\)$/is; + +// 진행률이 0~1을 벗어나지 않는 표준 키워드 +const NON_OVERSHOOT_KEYWORDS = new Set([ + 'linear', + 'ease', + 'ease-in', + 'ease-out', + 'ease-in-out', + 'step-start', + 'step-end', +]); + +// y(t) = 3(1-t)²t·y1 + 3(1-t)t²·y2 + t³ 의 [0, 1] 구간 최소·최대. +// 도함수(2차식) 근과 양 끝점만 후보로 보면 정확값이 나온다 +const cubicBezierYRange = ( + y1: number, + y2: number, +): { min: number; max: number } => { + const evaluate = (t: number): number => { + const u = 1 - t; + return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t; + }; + // dy/dt / 3 = a·t² + b·t + c + const a = 3 * y1 - 3 * y2 + 1; + const b = 2 * y2 - 4 * y1; + const c = y1; + const candidates = [0, 1]; + if (Math.abs(a) < 1e-12) { + if (Math.abs(b) > 1e-12) candidates.push(-c / b); + } else { + const discriminant = b * b - 4 * a * c; + if (discriminant >= 0) { + const sqrtDiscriminant = Math.sqrt(discriminant); + candidates.push( + (-b - sqrtDiscriminant) / (2 * a), + (-b + sqrtDiscriminant) / (2 * a), + ); + } + } + let min = Infinity; + let max = -Infinity; + for (const t of candidates) { + if (t < 0 || t > 1) continue; + const value = evaluate(t); + min = Math.min(min, value); + max = Math.max(max, value); + } + return { min, max }; +}; + +// easing 출력(보간 진행률)의 최소·최대. +// CSS가 해석하지 못하는 문자열은 transition 선언 자체가 무효라 스냅 전환이 되고, +// 보간이 일어나지 않으므로 [0, 1]로 본다 +export const easingOutputRange = ( + easing: string, +): { min: number; max: number } => { + const trimmed = easing.trim(); + const lower = trimmed.toLowerCase(); + if (NON_OVERSHOOT_KEYWORDS.has(lower) || lower.startsWith('steps(')) { + return { min: 0, max: 1 }; + } + const bezier = CUBIC_BEZIER_RE.exec(trimmed); + if (bezier) { + const [x1, y1, x2, y2] = bezier.slice(1, 5).map(Number); + // x 제어점이 0~1 밖이면 무효 선언, 스냅 전환 + if (!(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1)) { + return { min: 0, max: 1 }; + } + const range = cubicBezierYRange(y1, y2); + return { min: Math.min(0, range.min), max: Math.max(1, range.max) }; + } + const linearFn = LINEAR_FN_RE.exec(trimmed); + if (linearFn) { + // linear()는 구간별 선형이라 극값이 항상 정지점에 있다 + let min = 0; + let max = 1; + for (const stop of linearFn[1].split(',')) { + const value = Number.parseFloat(stop.trim()); + if (!Number.isFinite(value)) return { min: 0, max: 1 }; + min = Math.min(min, value); + max = Math.max(max, value); + } + return { min, max }; + } + return { min: 0, max: 1 }; +}; + +// 진행률 p가 [0, 1]을 벗어나는 최대 폭 +const easingOvershootEpsilon = (easing: string): number => { + const { min, max } = easingOutputRange(easing); + return Math.max(max - 1, -min, 0); +}; + +// 지나침 폭이 1 이상이면 재타깃 반복마다 지나침 위에서 다시 출발해 누적이 +// 발산하고, 어떤 유한한 창 여유로도 클리핑을 막을 수 없다. 그런 easing은 +// 렌더에서 표준 곡선으로 강등해 도달 계산과 실제 전환이 같은 상한을 공유한다 +export const SPRITE_SAFE_FALLBACK_EASING = 'ease'; + +export const resolveSpriteRenderEasing = (easing: string): string => + easingOvershootEpsilon(easing) < 1 ? easing : SPRITE_SAFE_FALLBACK_EASING; + +// 전환이 목표를 지나칠 수 있는 비율. 보간값 v = a + p(b - a)에서 진행률 p가 +// [0, 1]을 벗어나는 최대 폭 e를 구하고, 전환 중 재타깃이 반복되면 지나침 위에서 +// 다시 출발해 누적되므로 기하급수 극한 e/(1-e)를 상한으로 쓴다. +// e >= 1은 렌더가 폴백 곡선으로 강등하므로 강등 후 곡선 기준으로 계산한다 +export const easingOvershootExtension = (easing: string): number => { + const epsilon = easingOvershootEpsilon(resolveSpriteRenderEasing(easing)); + if (epsilon === 0) return 0; + return epsilon / (1 - epsilon); +}; + +// 렌더 가능한 이미지가 하나도 없으면 null. +// 해석기 4단계의 평균 상태는 x·y·scale이 상태 값들의 볼록 결합이라 아래 +// 범위 계산에 자동으로 포함되고, rotation 원형 평균은 회전 동일 시 같은 각, +// 상이 시 원 상한이 모든 각을 커버한다 +export const computeSpriteReachAabb = ( + sprite: SpriteReachGeometry, +): SpriteAabb | null => { + const hasImage = + sprite.baseImage != null || + sprite.poses.some((pose) => pose.imageOverride != null); + if (!hasImage) return null; + + const transforms: SpriteTransform[] = [ + sprite.idleTransform, + ...sprite.poses.map((pose) => pose.transform), + ]; + + const extension = easingOvershootExtension(sprite.transitionEasing); + const extendedRange = (values: number[]): { lo: number; hi: number } => { + const lo = Math.min(...values); + const hi = Math.max(...values); + const margin = extension * (hi - lo); + return { lo: lo - margin, hi: hi + margin }; + }; + const offsetX = extendedRange(transforms.map((t) => t.x)); + const offsetY = extendedRange(transforms.map((t) => t.y)); + const scale = extendedRange(transforms.map((t) => t.scale)); + const rotationsDiffer = transforms.some( + (t) => t.rotation !== transforms[0].rotation, + ); + + const { imageRect, pivot } = sprite; + const pivotX = imageRect.x + pivot.x * imageRect.width; + const pivotY = imageRect.y + pivot.y * imageRect.height; + // pivot 기준 상대 좌표 네 모서리 + const corners: Array<[number, number]> = [ + [imageRect.x - pivotX, imageRect.y - pivotY], + [imageRect.x + imageRect.width - pivotX, imageRect.y - pivotY], + [imageRect.x - pivotX, imageRect.y + imageRect.height - pivotY], + [ + imageRect.x + imageRect.width - pivotX, + imageRect.y + imageRect.height - pivotY, + ], + ]; + + if (rotationsDiffer) { + // 상태 간 회전이 다르면 전환 중간 각의 AABB가 양 끝 AABB를 벗어날 수 있다. + // pivot에서 최원점까지 반경의 원을 상한으로 각 offset 위치에 적용해 합집합 + let radius = 0; + for (const [vx, vy] of corners) { + radius = Math.max(radius, Math.hypot(vx, vy)); + } + radius *= Math.max(Math.abs(scale.lo), Math.abs(scale.hi)); + return { + minX: pivotX + offsetX.lo - radius, + minY: pivotY + offsetY.lo - radius, + maxX: pivotX + offsetX.hi + radius, + maxY: pivotY + offsetY.hi + radius, + }; + } + + // 회전 동일: CSS rotate와 같은 방향(y축 아래, 양수 시계방향)으로 모서리 변환. + // 모서리 좌표는 scale·offset에 선형이라 극단값 조합만 보면 된다 + const angle = transforms[0].rotation * (Math.PI / 180); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + let cornerXLo = Infinity; + let cornerXHi = -Infinity; + let cornerYLo = Infinity; + let cornerYHi = -Infinity; + for (const [vx, vy] of corners) { + const rotatedX = cos * vx - sin * vy; + const rotatedY = sin * vx + cos * vy; + for (const s of [scale.lo, scale.hi]) { + cornerXLo = Math.min(cornerXLo, rotatedX * s); + cornerXHi = Math.max(cornerXHi, rotatedX * s); + cornerYLo = Math.min(cornerYLo, rotatedY * s); + cornerYHi = Math.max(cornerYHi, rotatedY * s); + } + } + return { + minX: pivotX + offsetX.lo + cornerXLo, + minY: pivotY + offsetY.lo + cornerYLo, + maxX: pivotX + offsetX.hi + cornerXHi, + maxY: pivotY + offsetY.hi + cornerYHi, + }; +}; diff --git a/src/renderer/utils/sprite/spriteWireShape.test.ts b/src/renderer/utils/sprite/spriteWireShape.test.ts new file mode 100644 index 000000000..32e1ab5fd --- /dev/null +++ b/src/renderer/utils/sprite/spriteWireShape.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import type { + ReactiveSpritePosition, + SpritePose, +} from '@src/types/key/sprites'; + +import { toSpriteWireShape } from './spriteWireShape'; + +const basePose = (overrides: Partial = {}): SpritePose => ({ + poseId: 'pose-1', + triggers: ['key-1'], + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + ...overrides, +}); + +const basePosition = (): ReactiveSpritePosition => ({ + id: 'sprite-1', + dx: 0, + dy: 0, + width: 200, + height: 200, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 0, y: 0, width: 200, height: 200 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', +}); + +describe('toSpriteWireShape', () => { + it('명시 null layerName·groupId 키를 제거한다', () => { + const shaped = toSpriteWireShape({ + ...basePosition(), + layerName: null, + groupId: null, + }); + + expect('layerName' in shaped).toBe(false); + expect('groupId' in shaped).toBe(false); + }); + + it('명시 undefined 키도 제거한다', () => { + const shaped = toSpriteWireShape({ + ...basePosition(), + layerName: undefined, + groupId: undefined, + }); + + expect('layerName' in shaped).toBe(false); + expect('groupId' in shaped).toBe(false); + }); + + it('문자열 값은 보존한다', () => { + const shaped = toSpriteWireShape({ + ...basePosition(), + layerName: '왼손', + groupId: 'group-1', + }); + + expect(shaped.layerName).toBe('왼손'); + expect(shaped.groupId).toBe('group-1'); + }); + + it('두 키가 이미 부재면 같은 객체를 반환한다', () => { + const position = basePosition(); + + expect(toSpriteWireShape(position)).toBe(position); + }); + + it('자세의 명시 null name 키를 제거하고 문자열 이름은 보존한다', () => { + const shaped = toSpriteWireShape({ + ...basePosition(), + poses: [ + basePose({ name: null }), + basePose({ poseId: 'pose-2', name: '왼팔 올림' }), + ], + }); + + expect('name' in shaped.poses[0]).toBe(false); + expect(shaped.poses[1].name).toBe('왼팔 올림'); + }); + + it('자세 name 키가 이미 부재면 poses 배열을 재생성하지 않는다', () => { + const position = { ...basePosition(), poses: [basePose()] }; + + expect(toSpriteWireShape(position)).toBe(position); + }); + + it('다른 null 필드는 건드리지 않는다', () => { + const shaped = toSpriteWireShape({ + ...basePosition(), + layerName: null, + groupId: 'group-1', + }); + + expect(shaped.baseImage).toBeNull(); + expect(shaped.className).toBeNull(); + expect(shaped.useInlineStyles).toBeNull(); + expect(shaped.zIndex).toBeNull(); + expect(shaped.imageFit).toBeNull(); + expect(shaped.groupId).toBe('group-1'); + }); +}); diff --git a/src/renderer/utils/sprite/spriteWireShape.ts b/src/renderer/utils/sprite/spriteWireShape.ts new file mode 100644 index 000000000..c3f9f2d27 --- /dev/null +++ b/src/renderer/utils/sprite/spriteWireShape.ts @@ -0,0 +1,33 @@ +import type { + ReactiveSpritePosition, + SpritePose, +} from '@src/types/key/sprites'; + +// 자세 name도 같은 관례 - None이면 키 생략 +const toPoseWireShape = (pose: SpritePose): SpritePose => { + if (!(pose.name == null && 'name' in pose)) return pose; + const next = { ...pose }; + delete next.name; + return next; +}; + +// 백엔드는 layerName·groupId(위치)와 name(자세)이 None이면 키를 직렬화에서 생략한다. +// 프론트 생성·병합 경로가 명시 null을 남기면 스토어와 ack 문서가 갈라져 +// 낙관 적용 소유권 검사가 조용히 스킵되므로 이 키들을 wire 형태로 맞춘다. +// 대상은 정확히 이 필드들뿐 - baseImage 등 다른 null 필드는 백엔드가 +// null을 그대로 직렬화한다 +export const toSpriteWireShape = ( + position: T, +): T => { + const dropLayerName = position.layerName == null && 'layerName' in position; + const dropGroupId = position.groupId == null && 'groupId' in position; + const dropPoseNames = position.poses.some( + (pose) => pose.name == null && 'name' in pose, + ); + if (!dropLayerName && !dropGroupId && !dropPoseNames) return position; + const next: ReactiveSpritePosition = { ...position }; + if (dropLayerName) delete next.layerName; + if (dropGroupId) delete next.groupId; + if (dropPoseNames) next.poses = position.poses.map(toPoseWireShape); + return next as T; +}; diff --git a/src/renderer/windows/overlay/App.spritePreview.test.tsx b/src/renderer/windows/overlay/App.spritePreview.test.tsx index 89d4282a7..6f4b0da3f 100644 --- a/src/renderer/windows/overlay/App.spritePreview.test.tsx +++ b/src/renderer/windows/overlay/App.spritePreview.test.tsx @@ -1,6 +1,6 @@ -// 오버레이 창의 스프라이트 프리뷰 합성 계약 -// 메인 캔버스 드래그·스크럽의 원격 patch가 커밋 전에도 레이아웃 입력에 -// 반영되고, cancel·커밋 정산 후에는 canonical 참조가 그대로 돌아와야 한다 +// 오버레이 창의 스프라이트 반영 계약 +// 오버레이는 방송 화면이라 스크럽 프리뷰 봉투를 반영하지 않고 +// 커밋 정산된 canonical만 레이아웃·씬 입력으로 쓴다 import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -228,33 +228,16 @@ describe('overlay sprite preview composition', () => { vi.restoreAllMocks(); }); - it('원격 patch는 canonical을 건드리지 않고 레이아웃·씬 입력에만 합성된다', () => { + it('원격 프리뷰 patch는 오버레이 레이아웃·씬 입력에 반영되지 않는다', () => { expect(lastLayoutSprites()[0].dx).toBe(15); act(() => { previewOverlay.applyRemoteEnvelope(remoteEnvelope()); }); - expect(lastLayoutSprites()[0].dx).toBe(99); - expect(lastSceneSprites()[0].dx).toBe(99); - // canonical 불가침 - expect(useSpriteStore.getState().positions['4key'][0].dx).toBe(15); - }); - - it('cancel은 합성을 걷어내고 canonical 참조를 그대로 돌려준다', () => { - act(() => { - previewOverlay.applyRemoteEnvelope(remoteEnvelope()); - }); - expect(lastLayoutSprites()[0].dx).toBe(99); - - act(() => { - previewOverlay.applyRemoteEnvelope( - remoteEnvelope({ kind: 'cancel', seq: 2 }), - ); - }); - expect(lastLayoutSprites()[0].dx).toBe(15); - // 프리뷰가 없으면 identity 보존 - layout memo 재계산 없음 + expect(lastSceneSprites()[0].dx).toBe(15); + // canonical 참조가 그대로 유지되어 layout memo 재계산도 없다 expect(lastLayoutSprites()).toBe( useSpriteStore.getState().positions['4key'], ); diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index f6683d04a..cc57c6d7c 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -1,10 +1,4 @@ -import React, { - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { currentMonitor, getCurrentWindow, @@ -57,11 +51,6 @@ import { slotDisplayName, } from '@utils/keySlot'; import { buildSpriteKeyCanonicalMap } from '@utils/sprite/spriteKeyBinding'; -import { - composePreviewPositions, - getPreviewOverlayVersion, - subscribePreviewOverlay, -} from '@src/renderer/editor/runtime/previewOverlay'; import type { KeySlot } from '@src/types/key/keys'; type KeyDelayTimerHandle = ReturnType; @@ -784,21 +773,10 @@ export default function App() { const currentStatPositions = statPositions[selectedKeyType] ?? EMPTY_SLICE; const currentGraphPositions = graphPositions[selectedKeyType] ?? EMPTY_SLICE; const currentKnobPositions = knobPositions[selectedKeyType] ?? EMPTY_SLICE; - // 키는 useKeyStore.positions가 이미 프리뷰 합성본이지만 sprite는 canonical만 - // 온다 - 세션 patch를 렌더 시점에 합성해야 메인 캔버스 드래그·스크럽이 - // 커밋 전에도 오버레이에 반영된다. 프리뷰가 없으면 canonical 참조가 그대로 - // 돌아와 layout memo가 재계산되지 않는다 - useSyncExternalStore( - subscribePreviewOverlay, - getPreviewOverlayVersion, - getPreviewOverlayVersion, - ); - const renderedSpritePositions = composePreviewPositions( - 'spritePosition', - spritePositions, - ); + // 오버레이는 방송 화면이라 스크럽 프리뷰를 구독하지 않는다 (커밋 시점만 반영). + // 편집 중 실시간 피드백은 편집 캔버스 몫 const currentSpritePositions = - renderedSpritePositions[selectedKeyType] ?? EMPTY_SLICE; + spritePositions[selectedKeyType] ?? EMPTY_SLICE; // 스프라이트 트리거(키 요소 id) -> canonical 키 문자열, 잎이 시그널을 찾을 때 쓴다 const spriteKeyCanonicalMap = useMemo( @@ -839,6 +817,7 @@ export default function App() { ); const { bounds, + backgroundBox, displayPositions, displayStatPositions, displayGraphPositions, @@ -852,7 +831,9 @@ export default function App() { // 레이아웃이 DOM에 반영된 뒤 키 rect를 실측해 네이티브 히트 창과 동기화 useOverlayHitRegions(layout); - // 창 크기와 배경 박스가 같은 공식을 공유 (창 == 콘텐츠 박스 불변식) + // 창 크기는 창 바운즈(스프라이트 이미지 도달 범위 포함) 기준. + // 배경 박스는 computeLayout의 backgroundBox(콘텐츠 바운즈 기준)를 쓰므로 + // 스프라이트 오버행이 있을 때만 창이 배경보다 커지고 여유 영역은 투명하다. // 높이는 computeLayout의 topOffset을 그대로 재사용 - 공식이 한 곳에만 있다 const contentSize = useMemo( () => @@ -970,7 +951,7 @@ export default function App() { spriteKeyCanonicalMap={spriteKeyCanonicalMap} selectedKeyType={selectedKeyType} noteEffect={noteEffect} - contentSize={contentSize} + contentSize={backgroundBox ?? undefined} contentFade={contentFade} revealed={revealed} noteSettings={noteSettings} diff --git a/src/types/key/sprites.test.ts b/src/types/key/sprites.test.ts new file mode 100644 index 000000000..cd94884e3 --- /dev/null +++ b/src/types/key/sprites.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + findDuplicateTriggerPose, + reactiveSpritePositionSchema, + type SpritePose, +} from './sprites'; + +// 백엔드 직렬화 실물 형태. layerName·groupId는 None이면 키 자체가 생략된다 +// (다른 요소 위치와 같은 관례) - 부재를 거부하면 부트스트랩 검증이 통째로 실패한다 +const backendWireSprite = { + activation: 'whileHeld', + baseImage: null, + className: null, + dx: 625, + dy: 130, + height: 215, + hidden: false, + id: '566b0333-494c-4d47-a76d-506b71e5ac4c', + idleTransform: { rotation: 0, scale: 1, x: 0, y: 0 }, + imageFit: 'contain', + imageRect: { height: 200, width: 200, x: 0, y: 0 }, + pivot: { x: 0.5, y: 0.5 }, + poses: [], + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionMs: 90, + useInlineStyles: null, + width: 210, + zIndex: null, +}; + +describe('reactiveSpritePositionSchema', () => { + it('layerName·groupId가 생략된 백엔드 직렬화 형태를 허용한다', () => { + expect( + reactiveSpritePositionSchema.safeParse(backendWireSprite).success, + ).toBe(true); + }); + + it('layerName·groupId가 null로 온 형태도 허용한다', () => { + expect( + reactiveSpritePositionSchema.safeParse({ + ...backendWireSprite, + layerName: null, + groupId: null, + }).success, + ).toBe(true); + }); + + it('layerName·groupId 문자열 값을 보존 허용한다', () => { + expect( + reactiveSpritePositionSchema.safeParse({ + ...backendWireSprite, + layerName: '왼손', + groupId: 'group-1', + }).success, + ).toBe(true); + }); +}); + +describe('findDuplicateTriggerPose', () => { + const pose = (poseId: string, triggers: string[]): SpritePose => ({ + poseId, + triggers, + matchMode: 'exact', + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }); + + it('빈 트리거 자세는 미완성 draft라 중복으로 판정하지 않는다', () => { + expect(findDuplicateTriggerPose([pose('a', []), pose('b', [])])).toBeNull(); + }); + + it('순서·중복 트리거가 달라도 같은 집합이면 뒤 자세를 반환한다', () => { + const duplicate = pose('b', ['k2', 'k1', 'k1']); + expect(findDuplicateTriggerPose([pose('a', ['k1', 'k2']), duplicate])).toBe( + duplicate, + ); + }); + + it('빈 자세가 섞여 있어도 실제 중복은 검출한다', () => { + const duplicate = pose('c', ['k1']); + expect( + findDuplicateTriggerPose([pose('a', ['k1']), pose('b', []), duplicate]), + ).toBe(duplicate); + }); + + it('서로 다른 집합은 중복이 아니다', () => { + expect( + findDuplicateTriggerPose([pose('a', ['k1']), pose('b', ['k1', 'k2'])]), + ).toBeNull(); + }); +}); diff --git a/src/types/key/sprites.ts b/src/types/key/sprites.ts index 1934ec832..018a86359 100644 --- a/src/types/key/sprites.ts +++ b/src/types/key/sprites.ts @@ -90,6 +90,8 @@ export type SpriteActivation = z.infer; export const spritePoseSchema = z.object({ poseId: z.string().min(1), + // 사용자 지정 이름. 없으면 UI가 '상태 N'으로 표시하고, 백엔드는 None이면 키를 생략한다 + name: z.string().nullish(), // 키 요소 id 목록. 물리 키가 아니라 레인에 결합해서 키 매핑을 바꿔도 자리를 유지한다 triggers: z.array(z.string().min(1)).min(1), matchMode: spriteMatchModeSchema, @@ -108,8 +110,9 @@ export const reactiveSpritePositionSchema = z.object({ height: z.number().finite().positive(), hidden: z.boolean(), zIndex: z.number().finite().nullable(), - layerName: z.string().nullable(), - groupId: z.string().nullable(), + // 백엔드는 다른 요소 위치와 같이 None이면 두 필드를 직렬화에서 생략한다 + layerName: z.string().nullish(), + groupId: z.string().nullish(), className: z.string().nullable(), useInlineStyles: z.boolean().nullable(), @@ -138,12 +141,14 @@ export type SpritePositions = Record; export const DEFAULT_SPRITE_TRANSITION_MS = 90; export const DEFAULT_SPRITE_TRANSITION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'; -// 같은 담당 키 집합을 가진 자세가 둘이면 해석이 모호해진다. 저장 전에 걸러낸다 +// 같은 담당 키 집합을 가진 자세가 둘이면 해석이 모호해진다. 저장 전에 걸러낸다. +// 빈 트리거는 중복이 아니라 미완성 draft다 - 백엔드 검증(EMPTY vs DUPLICATE)과 같은 구분 export const findDuplicateTriggerPose = ( poses: readonly SpritePose[], ): SpritePose | null => { const seen = new Set(); for (const pose of poses) { + if (pose.triggers.length === 0) continue; // 집합 비교라 pose 안의 중복 트리거도 무시한다 const key = [...new Set(pose.triggers)].sort().join(' '); if (seen.has(key)) return pose; From 39dec720d257c4ed2435756377ba2c2f249c9d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 31 Aug 2026 06:51:43 +0900 Subject: [PATCH 008/125] =?UTF-8?q?fix:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=EC=9D=B4=20=ED=9E=88=ED=8A=B8=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=EC=97=90=EC=84=9C=20=EB=B9=A0=EC=A0=B8=20=EC=9E=88?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../renderedElementContract.test.tsx | 50 +++++++++++++++++++ .../overlay/counters/OverlaySpriteItem.tsx | 4 ++ .../counters/overlaySpriteItem.test.tsx | 4 +- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/renderer/__tests__/renderedElementContract.test.tsx b/src/renderer/__tests__/renderedElementContract.test.tsx index a54e6b395..2aadb9b3e 100644 --- a/src/renderer/__tests__/renderedElementContract.test.tsx +++ b/src/renderer/__tests__/renderedElementContract.test.tsx @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Key } from '@components/shared/Key'; import StatItem from '@components/overlay/counters/StatItem'; import OverlayKnobItem from '@components/overlay/counters/OverlayKnobItem'; +import OverlaySpriteItem from '@components/overlay/counters/OverlaySpriteItem'; import OutsideCounter from '@components/overlay/counters/OutsideCounter'; import GraphPanel from '@components/shared/GraphPanel'; import { resetAllKeySignals, setKeyActive } from '@stores/signals/keySignals'; @@ -338,6 +339,55 @@ describe('렌더 DOM 계약', () => { }); }); + describe('오버레이 SpriteItem', () => { + it('루트에 data 속성과 히트 마커를 싣는다', () => { + act(() => { + root.render( + , + ); + }); + const el = host.querySelector( + '[data-sprite-element="true"]', + ); + expect(el).not.toBeNull(); + // 다른 표시 요소와 같은 히트 참여 - 마커가 없으면 오버레이 클릭이 관통한다 + expect(el!.dataset.overlayHit).toBe('true'); + expect(el!.getAttribute('data-state')).toBe('idle'); + }); + }); + describe('오버레이 외부 카운터', () => { it('실시간 숫자 노드는 별도 히트 영역을 만들지 않는다', () => { host.innerHTML = renderToStaticMarkup( diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 271babbf8..ba2e26bf7 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -82,6 +82,9 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ if (position.hidden) return null; + // 히트 기준은 활동 영역 박스(루트) - 에디터 선택 박스와 같은 의미이고 포즈 전환 + // 중에도 rect가 안정적이다. 이미지 박스는 transform을 따라 움직여 실측 시점에 + // 따라 어긋나므로 히트 표식을 img로 옮기지 않는다 return (
0 ? 'active' : 'idle'} > {imageSrc && ( diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index 0d8676ff9..1683bda2e 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -107,8 +107,8 @@ describe('OverlaySpriteItem', () => { expect(el?.style.height).toBe('200px'); expect(el?.style.zIndex).toBe('5'); expect(el?.style.pointerEvents).toBe('none'); - // 시각 전용 - 히트 마커 없음 - expect(el?.hasAttribute('data-overlay-hit')).toBe(false); + // 히트 마커로 네이티브 히트 패널에 참여 - 상호작용은 패널 몫이라 pointer-events는 none 유지 + expect(el?.dataset.overlayHit).toBe('true'); const img = imgEl(); expect(img?.getAttribute('src')).toBe(BASE_IMAGE); From 0cc9c368bb6774285c41bb6e3540028c517c076b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 31 Aug 2026 06:51:50 +0900 Subject: [PATCH 009/125] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B2=BD=EB=A1=9C=EC=99=80=20=EC=9E=94=EC=9E=AC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/gen/schemas/acl-manifests.json | 2 +- src-tauri/permissions/dmnote-allow-all.json | 1 - src-tauri/src/app.rs | 1 - src-tauri/src/commands/layout/mod.rs | 1 - src-tauri/src/commands/layout/sprite_items.rs | 8 -- src-tauri/src/services/obs_bridge.rs | 4 +- src/renderer/api/modules/itemsApi.ts | 8 +- .../Modal/content/pickers/ImagePicker.tsx | 132 ++++++++---------- src/renderer/stores/data/useSpriteStore.ts | 4 - 9 files changed, 63 insertions(+), 98 deletions(-) delete mode 100644 src-tauri/src/commands/layout/sprite_items.rs diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index a6181e9db..a6f82f764 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","commit_gesture","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_fetch_import","css_get","css_get_use","css_history_activate","css_history_get","css_history_remove","css_load","css_reset","css_set_content","css_tab_activate_history","css_tab_clear","css_tab_export","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","editor_commit","editor_get","editor_preview_cancel","editor_preview_publish","editor_preview_subscribe","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","history_redo","history_status","history_undo","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","knob_positions_get","knob_positions_update","layer_groups_get","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_hit_renderer_ready","overlay_reset_position","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","overlay_sync_hit_regions","overlay_transition_fade","panel_drag_disarm_dock_zone","panel_drag_hit_test","panel_drag_present_and_start","panel_drag_start_existing","panel_window_apply_native_chrome","panel_window_arm_open","panel_window_close_ack","panel_window_dock","panel_window_drag_context","panel_window_move_to","panel_window_present","panel_window_present_at","panel_window_reset_position","panel_window_set_drag_cursor","panel_window_take_restore_request","plugin_authority_reset","plugin_bridge_send","plugin_bridge_send_to","plugin_group_refs_get","plugin_instances_commit","plugin_instances_get","plugin_instances_reconcile","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","sprite_positions_get","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"dmnote-allow-all":{"identifier":"dmnote-allow-all","description":"Full DM Note command access for renderer","commands":{"allow":["app_auto_update","app_bootstrap","app_cancel_editor_flush","app_open_external","app_quit","app_quit_after_editor_flush","app_restart","commit_gesture","counter_animation_create","counter_animation_delete","counter_animation_list","counter_animation_update","css_fetch_import","css_get","css_get_use","css_history_activate","css_history_get","css_history_remove","css_load","css_reset","css_set_content","css_tab_activate_history","css_tab_clear","css_tab_export","css_tab_get","css_tab_get_all","css_tab_load","css_tab_set","css_tab_toggle","css_toggle","custom_tabs_create","custom_tabs_delete","custom_tabs_list","custom_tabs_restore","custom_tabs_select","editor_commit","editor_get","editor_preview_cancel","editor_preview_publish","editor_preview_subscribe","font_load","get_cursor_settings","graph_positions_get","graph_positions_update","history_redo","history_status","history_undo","image_load","js_get","js_get_use","js_load","js_reload","js_remove_plugin","js_reset","js_set_content","js_set_plugin_enabled","js_toggle","key_sound_get_output_state","key_sound_get_status","key_sound_list_output_devices","key_sound_load_soundpack","key_sound_set_enabled","key_sound_set_latency_logging","key_sound_set_output_backend","key_sound_set_volume","key_sound_unload_soundpack","keys_get","keys_get_counters","keys_reset_all","keys_reset_counters","keys_reset_counters_mode","keys_reset_mode","keys_reset_single_counter","keys_set_counters","keys_set_mode","knob_positions_get","knob_positions_update","layer_groups_get","note_tab_clear","note_tab_get","note_tab_get_all","note_tab_set","obs_regenerate_token","obs_start","obs_status","obs_stop","overlay_get","overlay_hit_renderer_ready","overlay_reset_position","overlay_resize","overlay_set_anchor","overlay_set_lock","overlay_set_visible","overlay_sync_hit_regions","overlay_transition_fade","panel_drag_disarm_dock_zone","panel_drag_hit_test","panel_drag_present_and_start","panel_drag_start_existing","panel_window_apply_native_chrome","panel_window_arm_open","panel_window_close_ack","panel_window_dock","panel_window_drag_context","panel_window_move_to","panel_window_present","panel_window_present_at","panel_window_reset_position","panel_window_set_drag_cursor","panel_window_take_restore_request","plugin_authority_reset","plugin_bridge_send","plugin_bridge_send_to","plugin_group_refs_get","plugin_instances_commit","plugin_instances_get","plugin_instances_reconcile","plugin_storage_clear","plugin_storage_clear_by_prefix","plugin_storage_get","plugin_storage_has_data","plugin_storage_keys","plugin_storage_remove","plugin_storage_set","positions_get","preset_load","preset_load_tab","preset_save","preset_save_tab","raw_input_subscribe","raw_input_unsubscribe","settings_get","settings_update","sound_delete","sound_list","sound_load","sound_load_original","sound_rename","sound_save_processed_wav","sound_set_enabled","sound_set_hidden","sound_update_processed_wav","stat_positions_get","stat_positions_update","window_close","window_minimize","window_open_devtools_all","window_show_main"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/permissions/dmnote-allow-all.json b/src-tauri/permissions/dmnote-allow-all.json index 95b15aa18..6416db88d 100644 --- a/src-tauri/permissions/dmnote-allow-all.json +++ b/src-tauri/permissions/dmnote-allow-all.json @@ -148,7 +148,6 @@ "sound_set_enabled", "sound_set_hidden", "sound_update_processed_wav", - "sprite_positions_get", "stat_positions_get", "stat_positions_update", "window_close", diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index a1f4d0747..c59f11e5e 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -315,7 +315,6 @@ pub fn run() { commands::layout::graph_items::graph_positions_update, commands::layout::knob_items::knob_positions_get, commands::layout::knob_items::knob_positions_update, - commands::layout::sprite_items::sprite_positions_get, commands::layout::font::font_load, commands::layout::overlay::overlay_get, commands::layout::overlay::overlay_set_visible, diff --git a/src-tauri/src/commands/layout/mod.rs b/src-tauri/src/commands/layout/mod.rs index 36ec495f5..15130b171 100644 --- a/src-tauri/src/commands/layout/mod.rs +++ b/src-tauri/src/commands/layout/mod.rs @@ -5,5 +5,4 @@ pub mod overlay; pub mod panel; mod panel_drag_cursor; pub mod settings; -pub mod sprite_items; pub mod stat_items; diff --git a/src-tauri/src/commands/layout/sprite_items.rs b/src-tauri/src/commands/layout/sprite_items.rs deleted file mode 100644 index 790ad0c50..000000000 --- a/src-tauri/src/commands/layout/sprite_items.rs +++ /dev/null @@ -1,8 +0,0 @@ -use tauri::AppHandle; - -use crate::{commands::run_blocking, errors::CmdResult, models::SpritePositions}; - -#[tauri::command] -pub async fn sprite_positions_get(app: AppHandle) -> CmdResult { - run_blocking(app, |_, state| Ok(state.store.snapshot().sprite_positions)).await -} diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index f317f93c6..b53cbfa4b 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -54,7 +54,6 @@ const ALLOWED_WS_COMMANDS: &[&str] = &[ "stat_positions_get", "graph_positions_get", "knob_positions_get", - "sprite_positions_get", "custom_tabs_list", "sound_list", "sound_load_original", @@ -1395,10 +1394,9 @@ mod tests { #[test] fn websocket_allowlist_uses_exact_matching() { - assert_eq!(ALLOWED_WS_COMMANDS.len(), 33); + assert_eq!(ALLOWED_WS_COMMANDS.len(), 32); assert!(is_allowed_command("app_bootstrap")); assert!(is_allowed_command("editor_get")); - assert!(is_allowed_command("sprite_positions_get")); assert!(!is_allowed_command("css_history_get")); assert!(is_allowed_command("sound_list")); assert!(is_allowed_command("sound_load_original")); diff --git a/src/renderer/api/modules/itemsApi.ts b/src/renderer/api/modules/itemsApi.ts index 30cc20e57..707b3e89c 100644 --- a/src/renderer/api/modules/itemsApi.ts +++ b/src/renderer/api/modules/itemsApi.ts @@ -6,10 +6,7 @@ import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoord import type { StatItemPositions } from '@src/types/key/statItems'; import type { GraphItemPositions } from '@src/types/key/graphItems'; import type { KnobItemPositions } from '@src/types/key/knobs'; -import type { - ReactiveSpritePosition, - SpritePositions, -} from '@src/types/key/sprites'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import type { LayerGroups } from '@src/types/layerGroups'; @@ -62,7 +59,6 @@ export const knobItemsApi = { }; export const spriteItemsApi = { - getPositions: () => invoke('sprite_positions_get'), // 스프라이트 전용 필드는 ops patchElement가 거부하므로 전체 필드 패치로 커밋. // 호출 시점 캡처 전체 레코드는 동결 소유권 검사에 걸려 낙관 적용이 빠지므로 // 직렬 슬롯 안 최신 base에서 대상 스프라이트만 패치해 생성한다. 대상 소실이면 @@ -99,8 +95,6 @@ export const spriteItemsApi = { () => outcome, ); }, - onPositionsChanged: (listener: (positions: SpritePositions) => void) => - subscribe('spritePositions:changed', listener), }; export const layerGroupsApi = { diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx index 1f1323c9c..8cf855a52 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx @@ -187,10 +187,6 @@ const ImagePicker = ({ const showImageFit = typeof onIdleImageFitChange === 'function' || typeof onActiveImageFitChange === 'function'; - // 투명화 토글도 imageFit과 같은 관례 - 콜백 없는 호출처(스프라이트)에선 숨김 - const showTransparentToggle = - typeof onIdleTransparentChange === 'function' || - typeof onActiveTransparentChange === 'function'; const handleTransparentToggle = (): void => { if (effectiveMode === STATE_MODES.idle) { onIdleTransparentChange?.(!idleTransparent); @@ -412,76 +408,68 @@ const ImagePicker = ({
)} - {/* 설정 카드 - 노출할 컨트롤이 하나도 없으면(스프라이트) 카드 생략 */} - {(showTransparentToggle || - showImageFit || - typeof onImageModeChange === 'function') && ( - - {/* 키 투명화 토글 */} - {showTransparentToggle && ( -
-

- {t('imagePicker.transparent')} -

- -
- )} + {/* 설정 카드 */} + + {/* 키 투명화 토글 */} +
+

+ {t('imagePicker.transparent')} +

+ +
- {/* 이미지 맞춤 */} - {showImageFit && ( -
-

- {t('propertiesPanel.imageFit') || '표시'} -

- -
- )} + {/* 이미지 맞춤 */} + {showImageFit && ( +
+

+ {t('propertiesPanel.imageFit') || '표시'} +

+ +
+ )} - {/* 배치 모드 - 공통 */} - {typeof onImageModeChange === 'function' && ( -
-

- {t('imagePicker.mode')} -

- onImageModeChange(value as ImageMode)} - /> -
- )} -
- )} + {/* 배치 모드 - 공통 */} + {typeof onImageModeChange === 'function' && ( +
+

{t('imagePicker.mode')}

+ onImageModeChange(value as ImageMode)} + /> +
+ )} +
); }; diff --git a/src/renderer/stores/data/useSpriteStore.ts b/src/renderer/stores/data/useSpriteStore.ts index 0a65d9571..cc37cf023 100644 --- a/src/renderer/stores/data/useSpriteStore.ts +++ b/src/renderer/stores/data/useSpriteStore.ts @@ -5,14 +5,10 @@ type CanonicalSpritePositions = CanonicalEditorDocumentV1['spritePositions']; interface SpriteStoreState { positions: CanonicalSpritePositions; - isLocalUpdateInProgress: boolean; setPositions: (positions: CanonicalSpritePositions) => void; - setLocalUpdateInProgress: (value: boolean) => void; } export const useSpriteStore = create((set) => ({ positions: {}, - isLocalUpdateInProgress: false, setPositions: (positions) => set({ positions }), - setLocalUpdateInProgress: (value) => set({ isLocalUpdateInProgress: value }), })); From 145dbc73cc6ca09dcf85c1b03b8c2af73d811dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 31 Aug 2026 06:51:58 +0900 Subject: [PATCH 010/125] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../benchmarks/gridMarqueeBenchmark.tsx | 1 + .../main/Grid/PropertiesPanel/PanelIcons.tsx | 27 ++++++++++++++ .../Grid/PropertiesPanel/ShadowControls.tsx | 3 +- .../batch/BatchSelectionPanel.tsx | 27 +------------- .../batch/BatchStyleTabContent.tsx | 9 +++-- .../single/CounterTabContent.tsx | 3 +- .../single/SingleSelectionPanel.tsx | 36 ++++--------------- .../single/StyleTabContent.tsx | 11 +++--- .../components/main/Grid/core/Grid.tsx | 25 +++++-------- .../main/Grid/handles/groupResizeUtils.ts | 10 +++--- .../main/Grid/layers/SpriteItem.tsx | 4 +-- .../content/pickers/FontPickerOpenButton.tsx | 3 +- .../hooks/Grid/useGridMarquee.test.tsx | 2 ++ src/renderer/hooks/Grid/useGridMarquee.ts | 9 +++-- .../hooks/Grid/useSmartGuidesElements.ts | 8 ++++- src/renderer/utils/cardRecipes.ts | 8 +++-- 16 files changed, 89 insertions(+), 97 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PanelIcons.tsx diff --git a/src/renderer/benchmarks/gridMarqueeBenchmark.tsx b/src/renderer/benchmarks/gridMarqueeBenchmark.tsx index 698390309..8bab16b1f 100644 --- a/src/renderer/benchmarks/gridMarqueeBenchmark.tsx +++ b/src/renderer/benchmarks/gridMarqueeBenchmark.tsx @@ -18,6 +18,7 @@ export const GridMarqueeBenchmarkSurface = ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, selectedKeyType: 'benchmark', pluginElements: [], clientToGridCoords: (x, y) => ({ x, y }), diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PanelIcons.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PanelIcons.tsx new file mode 100644 index 000000000..b919e512d --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/PanelIcons.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +// 24 그리드를 12px로 렌더 - 스트로크 2.4가 화면상 1.2 +export const RenameIcon: React.FC = () => ( + +); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/ShadowControls.tsx b/src/renderer/components/main/Grid/PropertiesPanel/ShadowControls.tsx index a1f34dc19..0b47f0e39 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/ShadowControls.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/ShadowControls.tsx @@ -1,4 +1,5 @@ import React, { useRef, useState } from 'react'; +import { ACTION_BUTTON_CHROME_CLASS } from '@utils/cardRecipes'; import type { ElementShadowSpec, ElementShadowValuePatch, @@ -97,7 +98,7 @@ const ShadowControls = ({ + ) : null} +
+ ); +}; + +export default SpriteImagePreviewCard; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx index e6c1007df..87ecca4b4 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx @@ -6,12 +6,12 @@ import { PropertyRow, PropertySection, } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; -import { resolveImageSource } from '@utils/core/imageSource'; import { DEFAULT_SPRITE_IMAGE_FIT, type ReactiveSpritePosition, type SpriteImageFit, } from '@src/types/key/sprites'; +import SpriteImagePreviewCard from './SpriteImagePreviewCard'; interface SpriteImageSettingsPopupProps { open: boolean; @@ -48,7 +48,6 @@ const SpriteImageSettingsPopup: React.FC = ({ t, }) => { const fit = position.imageFit ?? DEFAULT_SPRITE_IMAGE_FIT; - const imageSrc = resolveImageSource(position.baseImage); const rectField = (patch: Partial) => ({ imageRect: { ...position.imageRect, ...patch }, @@ -66,52 +65,13 @@ const SpriteImageSettingsPopup: React.FC = ({ offsetY={-93} onClose={onClose} > - {/* 이미지 미리보기 - 전면 선택 버튼과 형제 초기화 버튼 (중첩 버튼 금지) */} -
- {/* 투명 격자 배경 */} -
- {imageSrc ? ( - - ) : null} - - ) : null} -
+ {/* 위치·크기·기준점 - 접두 글자와 % 접미로 구분하는 무라벨 그리드 */}
diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx index 6fadef089..09a30bb5c 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -6,15 +6,12 @@ import { AngleGlyph, ScaleGlyph, } from '@components/main/common/TransformGlyphs'; -import { - PropertyRow, - PropertySection, -} from '@components/main/Grid/PropertiesPanel/PropertyInputs'; -import { ACTION_BUTTON_CLASS } from '@utils/cardRecipes'; import { SPRITE_CONSTRAINTS, + type SpriteImageFit, type SpriteTransform, } from '@src/types/key/sprites'; +import SpriteImagePreviewCard from './SpriteImagePreviewCard'; const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); @@ -24,7 +21,9 @@ export interface SpritePoseControls { keyOptions: ReadonlyArray<{ id: string; label: string }>; triggers: readonly string[]; isDuplicate: boolean; - hasImageOverride: boolean; + imageOverride: string | null; + // 미리보기 표시 방식 - 캔버스 렌더가 스프라이트 imageFit을 따르므로 동일 적용 + imageFit: SpriteImageFit | null; onToggleTrigger: (keyId: string) => void; onImagePick: () => void; onImageReset: () => void; @@ -33,6 +32,8 @@ export interface SpritePoseControls { interface SpritePoseEditorPopupProps { open: boolean; ariaLabel: string; + // 편집 세션 신원 - 내부 subtree 재마운트와 앵커 재측정의 기준 + poseId: string; transform: SpriteTransform; referenceRef: React.RefObject; panelElement: HTMLElement | null; @@ -47,10 +48,13 @@ interface SpritePoseEditorPopupProps { } // 상태 하나의 편집 팝업 - 담당 키·변환·상태 이미지를 모아 패널 행을 요약으로 남긴다. -// 카드 규격·변환 그리드는 이미지 피커와 동일 +// 카드 규격·변환 그리드는 이미지 피커와 동일. +// 셸(PickerSurface)은 행 전환 동안 유지하고 편집 subtree만 poseId로 재마운트해 +// 입력 draft·포커스는 대상별로 끊고 전환 자체는 이어지게 한다 const SpritePoseEditorPopup: React.FC = ({ open, ariaLabel, + poseId, transform, referenceRef, panelElement, @@ -84,168 +88,159 @@ const SpritePoseEditorPopup: React.FC = ({ referenceRef={referenceRef} panelElement={panelElement} fallbackWidth={172} - fallbackHeight={220} + fallbackHeight={181} cardClassName="flex flex-col p-[8px] gap-[8px] w-[172px] rounded-popup" offsetY={-93} interactiveRefs={interactiveRefs} + anchorKey={poseId} onClose={onClose} > - {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? ( -

- {t('propertiesPanel.spriteNoKeys') || '이 모드에 키 요소가 없습니다'} -

- ) : ( - ({ - label: option.label, - value: option.id, - })), - // 죽은 참조도 항목으로 노출해 토글로 제거 - ...deadTriggers.map((id) => ({ - label: t('propertiesPanel.spriteMissingKey') || '삭제된 키', - value: id, - danger: true, - })), - ]} - multiple - values={[...poseControls.triggers]} - onChange={poseControls.onToggleTrigger} - ariaLabel={t('propertiesPanel.spriteTriggerKeys') || '담당 키'} - placeholder={ - t('propertiesPanel.spriteTriggerPlaceholder') || '키 선택' - } - fullWidth - // 중복 조합은 배너 대신 트리거 자체가 짧은 라벨 + 위험 톤 + 사유 툴팁 - danger={poseControls.isDuplicate} - dangerLabel={t('propertiesPanel.spriteDuplicateShort') || '중복 키'} - title={ - poseControls.isDuplicate - ? t('propertiesPanel.spriteDuplicateTriggers') || - '중복된 키 조합입니다' - : undefined - } + {/* poseId 경계 - 행 전환마다 편집 subtree만 새 세션으로 */} + + {/* 상태 이미지 - 비우면 캔버스가 기본 이미지를 그대로 쓴다 */} + - )} - {/* 위치·회전·배율 - 라벨 대신 접두 글리프가 의미를 맡는다 */} -
-
- - commitField({ x: clamp(value, offset.min, offset.max) }) - } - onPreview={ - previewField - ? (value) => - previewField({ x: clamp(value, offset.min, offset.max) }) - : undefined - } - onCancel={onTransformCancel} - prefix="X" - ariaLabel={`${t('propertiesPanel.position') || '위치'} X`} - width="100%" - min={offset.min} - max={offset.max} - allowDecimal - decimalScale={1} - /> - - commitField({ y: clamp(value, offset.min, offset.max) }) - } - onPreview={ - previewField - ? (value) => - previewField({ y: clamp(value, offset.min, offset.max) }) - : undefined - } - onCancel={onTransformCancel} - prefix="Y" - ariaLabel={`${t('propertiesPanel.position') || '위치'} Y`} - width="100%" - min={offset.min} - max={offset.max} - allowDecimal - decimalScale={1} - /> + {/* 위치·회전·배율 - 라벨 대신 접두 글리프가 의미를 맡는다 */} +
+
+ + commitField({ x: clamp(value, offset.min, offset.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ x: clamp(value, offset.min, offset.max) }) + : undefined + } + onCancel={onTransformCancel} + prefix="X" + ariaLabel={`${t('propertiesPanel.position') || '위치'} X`} + width="100%" + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> + + commitField({ y: clamp(value, offset.min, offset.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ y: clamp(value, offset.min, offset.max) }) + : undefined + } + onCancel={onTransformCancel} + prefix="Y" + ariaLabel={`${t('propertiesPanel.position') || '위치'} Y`} + width="100%" + min={offset.min} + max={offset.max} + allowDecimal + decimalScale={1} + /> +
+
+ + commitField({ + rotation: clamp(value, rotation.min, rotation.max), + }) + } + onPreview={ + previewField + ? (value) => + previewField({ + rotation: clamp(value, rotation.min, rotation.max), + }) + : undefined + } + onCancel={onTransformCancel} + prefix={} + ariaLabel={t('propertiesPanel.spriteRotation') || '회전'} + suffix="°" + width="100%" + min={rotation.min} + max={rotation.max} + allowDecimal + decimalScale={1} + /> + {/* 배율은 이미지 피커와 같은 정수 % 표기 - 저장은 배수 그대로 */} + + commitField({ scale: clamp(value / 100, scale.min, scale.max) }) + } + onPreview={ + previewField + ? (value) => + previewField({ + scale: clamp(value / 100, scale.min, scale.max), + }) + : undefined + } + onCancel={onTransformCancel} + prefix={} + ariaLabel={t('propertiesPanel.spriteScale') || '배율'} + suffix="%" + width="100%" + min={scale.min * 100} + max={scale.max * 100} + /> +
-
- - commitField({ - rotation: clamp(value, rotation.min, rotation.max), - }) - } - onPreview={ - previewField - ? (value) => - previewField({ - rotation: clamp(value, rotation.min, rotation.max), - }) - : undefined - } - onCancel={onTransformCancel} - prefix={} - ariaLabel={t('propertiesPanel.spriteRotation') || '회전'} - suffix="°" - width="100%" - min={rotation.min} - max={rotation.max} - allowDecimal - decimalScale={1} - /> - {/* 배율은 이미지 피커와 같은 정수 % 표기 - 저장은 배수 그대로 */} - - commitField({ scale: clamp(value / 100, scale.min, scale.max) }) + + {/* 담당 키 - 미리보기·변환 아래가 정위치, 신규 상태도 카드가 작아 한눈에 보인다 */} + {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? ( +

+ {t('propertiesPanel.spriteNoKeys') || + '이 모드에 키 요소가 없습니다'} +

+ ) : ( + ({ + label: option.label, + value: option.id, + })), + // 죽은 참조도 항목으로 노출해 토글로 제거 + ...deadTriggers.map((id) => ({ + label: t('propertiesPanel.spriteMissingKey') || '삭제된 키', + value: id, + danger: true, + })), + ]} + multiple + values={[...poseControls.triggers]} + onChange={poseControls.onToggleTrigger} + ariaLabel={t('propertiesPanel.spriteTriggerKeys') || '담당 키'} + placeholder={ + t('propertiesPanel.spriteTriggerPlaceholder') || '키 선택' } - onPreview={ - previewField - ? (value) => - previewField({ - scale: clamp(value / 100, scale.min, scale.max), - }) + fullWidth + // 중복 조합은 배너 대신 트리거 자체가 짧은 라벨 + 위험 톤 + 사유 툴팁 + danger={poseControls.isDuplicate} + dangerLabel={t('propertiesPanel.spriteDuplicateShort') || '중복 키'} + title={ + poseControls.isDuplicate + ? t('propertiesPanel.spriteDuplicateTriggers') || + '중복된 키 조합입니다' : undefined } - onCancel={onTransformCancel} - prefix={} - ariaLabel={t('propertiesPanel.spriteScale') || '배율'} - suffix="%" - width="100%" - min={scale.min * 100} - max={scale.max * 100} /> -
-
- - {/* 상태 이미지 - 안 고르면 기본 이미지가 그대로 쓰인다 */} - - - - {poseControls.hasImageOverride ? ( - - ) : null} - - + )} + ); }; diff --git a/src/renderer/hooks/ui/usePanelAnchoredPopupPosition.ts b/src/renderer/hooks/ui/usePanelAnchoredPopupPosition.ts index b17697393..e50b879d9 100644 --- a/src/renderer/hooks/ui/usePanelAnchoredPopupPosition.ts +++ b/src/renderer/hooks/ui/usePanelAnchoredPopupPosition.ts @@ -1,5 +1,11 @@ /* eslint-disable react-hooks/set-state-in-effect */ -import { useLayoutEffect, useRef, useState, type RefObject } from 'react'; +import { + useLayoutEffect, + useRef, + useState, + type Key, + type RefObject, +} from 'react'; import { clampToViewport } from '@utils/ui/popupGeometry'; interface PanelRect { @@ -122,6 +128,8 @@ interface UsePanelAnchoredPopupPositionOptions { popupRef: RefObject; fallbackWidth: number; fallbackHeight: number; + // 같은 ref 객체로 대상 행만 바꾸는 호출자의 앵커 신원 - 바뀌면 rect 재캡처 + anchorKey?: Key; } export const usePanelAnchoredPopupPosition = ({ @@ -131,6 +139,7 @@ export const usePanelAnchoredPopupPosition = ({ popupRef, fallbackWidth, fallbackHeight, + anchorKey, }: UsePanelAnchoredPopupPositionOptions): PopupPosition | null => { const [position, setPosition] = useState(null); const lastPositionRef = useRef(null); @@ -144,7 +153,9 @@ export const usePanelAnchoredPopupPosition = ({ return; } - // 앵커 중심은 열리는 시점에 한 번만 캡처 — 이후 패널 스크롤·리렌더에도 제자리 유지 + // 앵커 중심은 열리는(또는 anchorKey가 바뀌는) 시점에 한 번만 캡처 — + // 이후 패널 스크롤·리렌더에도 제자리 유지 + void anchorKey; // 값은 쓰지 않고 재실행 트리거로만 참조 const anchorElement = referenceRef?.current; const anchorRect = anchorElement?.getBoundingClientRect(); const capturedAnchorCenterY = anchorRect @@ -188,6 +199,7 @@ export const usePanelAnchoredPopupPosition = ({ computePositionRef.current = null; }; }, [ + anchorKey, fallbackHeight, fallbackWidth, open, @@ -212,6 +224,8 @@ interface UseTriggerAnchoredPopupPositionOptions { referenceRef?: RefObject; popupRef: RefObject; fallbackHeight: number; + // 같은 ref 객체로 대상 행만 바꾸는 호출자의 앵커 신원 - 바뀌면 rect 재캡처 + anchorKey?: Key; } export const useTriggerAnchoredPopupPosition = ({ @@ -219,6 +233,7 @@ export const useTriggerAnchoredPopupPosition = ({ referenceRef, popupRef, fallbackHeight, + anchorKey, }: UseTriggerAnchoredPopupPositionOptions): TriggerAnchoredResult => { const [result, setResult] = useState(UNSETTLED); const lastPositionRef = useRef(null); @@ -241,7 +256,9 @@ export const useTriggerAnchoredPopupPosition = ({ return; } - // 앵커는 열리는 시점에 한 번만 캡처 — 패널 스크롤·리렌더에도 제자리 유지 + // 앵커는 열리는(또는 anchorKey가 바뀌는) 시점에 한 번만 캡처 — + // 패널 스크롤·리렌더에도 제자리 유지 + void anchorKey; // 값은 쓰지 않고 재실행 트리거로만 참조 const triggerRect = trigger.getBoundingClientRect(); const sectionRect = section.getBoundingClientRect(); const ownerWindow = trigger.ownerDocument.defaultView ?? window; @@ -284,7 +301,7 @@ export const useTriggerAnchoredPopupPosition = ({ ownerWindow.removeEventListener('resize', compute); computePositionRef.current = null; }; - }, [fallbackHeight, open, popupRef, referenceRef]); + }, [anchorKey, fallbackHeight, open, popupRef, referenceRef]); useLayoutEffect(() => { computePositionRef.current?.(); diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index b73c8fe49..ee18e0601 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -554,7 +554,6 @@ "knobReverse": "Reverse Direction", "spriteBaseImage": "Base Image", "spriteImageSelect": "Select", - "spriteImageRemove": "Remove", "spriteImageRect": "Position", "spriteImageSize": "Size", "spritePivot": "Pivot", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index f40f4feaf..0317e580c 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -554,7 +554,6 @@ "knobReverse": "방향 반전", "spriteBaseImage": "기본 이미지", "spriteImageSelect": "선택", - "spriteImageRemove": "제거", "spriteImageRect": "위치", "spriteImageSize": "크기", "spritePivot": "기준점", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index b277de281..949be1e9e 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -559,7 +559,6 @@ "knobReverse": "Обратное направление", "spriteBaseImage": "Базовая картинка", "spriteImageSelect": "Выбрать", - "spriteImageRemove": "Убрать", "spriteImageRect": "Позиция", "spriteImageSize": "Размер", "spritePivot": "Точка опоры", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index a5735377e..6212ccea2 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -559,7 +559,6 @@ "knobReverse": "反轉方向", "spriteBaseImage": "基礎圖像", "spriteImageSelect": "選擇", - "spriteImageRemove": "移除", "spriteImageRect": "位置", "spriteImageSize": "大小", "spritePivot": "基準點", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 95afd7d3d..90030e817 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -559,7 +559,6 @@ "knobReverse": "反转方向", "spriteBaseImage": "基础图像", "spriteImageSelect": "选择", - "spriteImageRemove": "移除", "spriteImageRect": "位置", "spriteImageSize": "大小", "spritePivot": "基准点", From 5254fb335b3069426ee872293898b4e2227faff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 31 Aug 2026 17:49:11 +0900 Subject: [PATCH 024/125] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=99=9C=EB=8F=99=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=EC=A0=90=EC=84=A0=EC=9D=84=20=ED=98=B8=EB=B2=84=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/layers/SpriteItem.render.test.tsx | 12 ++++++-- .../main/Grid/layers/SpriteItem.tsx | 28 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index e9cb3e26a..d709c05f9 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -103,17 +103,25 @@ describe('SpriteItem 렌더', () => { }); it('선택 여부에 따라 활동 영역 테두리가 가이드와 선택 색을 오간다', () => { + // 이미지 없는 스프라이트는 가이드가 유일한 실체 - 점선 상시 표시 const idle = renderSprite(spritePosition()); expect(idle).not.toBeNull(); - expect(idle?.style.border).toContain('dashed'); + expect(idle?.className).toContain('border-dashed'); + expect(idle?.className).not.toContain('border-transparent'); expect(idle?.dataset.selected).toBeUndefined(); // 이미지가 없으면 자리표시자 렌더 expect( idle?.querySelector('[data-sprite-placeholder="true"]'), ).not.toBeNull(); + // 이미지가 있으면 점선은 소음이라 기본 투명, 호버에만 드러난다. + // 호버는 캡처를 받는 루트(group) 기준이라 눌린 동안에도 유지된다 + const withImage = renderSprite(spritePosition({ baseImage: 'hand.png' })); + expect(withImage?.className).toContain('border-transparent'); + expect(withImage?.className).toContain('group-hover/sprite:border-'); + const selected = renderSprite(spritePosition(), { isSelected: true }); - expect(selected?.style.border).toContain('var(--ui-selection-border)'); + expect(selected?.className).toContain('var(--ui-selection-border)'); expect(selected?.dataset.selected).toBe('true'); }); diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index afd3c6dd1..0bd9014f1 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -46,9 +46,20 @@ interface SpriteItemProps { isViewportTransforming?: boolean; } -// 활동 영역은 에디터 전용 가이드 - 오버레이에는 그리지 않는다. 복제 고스트도 같은 선을 쓴다 +// 활동 영역은 에디터 전용 가이드 - 오버레이에는 그리지 않는다. +// 아이템 자체는 호버에만 점선을 보이고(아래 클래스, 같은 색), 이 상수는 복제 고스트 몫 export const ACTIVITY_AREA_BORDER = '1px dashed rgba(237, 238, 242, 0.4)'; -const SELECTED_AREA_BORDER = '1px solid var(--ui-selection-border)'; + +// 이미지가 있으면 점선이 소음이라 호버에만, 없으면 가이드가 유일한 실체라 상시. +// 투명 보더를 유지해 호버 시 레이아웃이 밀리지 않는다. +// 호버 판정은 루트(group) 기준 - pointerdown 캡처가 자식 :hover를 끊어 +// 클릭 순간 점선이 한 프레임 꺼지는 깜빡임을 막는다 +const GUIDE_BORDER_CLASS = { + selected: 'border border-solid border-[var(--ui-selection-border)]', + hoverOnly: + 'border border-dashed border-transparent group-hover/sprite:border-[rgba(237,238,242,0.4)]', + always: 'border border-dashed border-[rgba(237,238,242,0.4)]', +} as const; // 캔버스의 스프라이트는 정적이다: 평소엔 idle 상태, 자세 팝업이 열려 있으면 // 그 자세를 그린다 (편집창 전용 프리뷰). 키 눌림 라이브 반응은 오버레이 창 몫 @@ -231,7 +242,9 @@ const SpriteItem = ({ return (
e.preventDefault()} >
Date: Mon, 31 Aug 2026 18:01:04 +0900 Subject: [PATCH 025/125] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=90=EC=84=B8=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=8B=9C=EA=B0=84=200=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/editor/page.mdx | 2 +- docs/content/ko/api-reference/editor/page.mdx | 2 +- src-tauri/src/models/mod.rs | 2 +- src-tauri/src/state/migration.rs | 2 +- src/types/key/sprites.ts | 2 +- tests/fixtures/sprite-constraint-parity.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index 49ed1c9c5..58384c3ff 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -97,7 +97,7 @@ interface ReactiveSpritePosition { pivot: { x: number; y: number }; // normalized transform origin, 0–1 each idleTransform: SpriteTransform; // base transform when no pose matches poses: SpritePose[]; // commits accept at most 64 - transitionMs: number; // pose blend duration, 0–1000, default 90 + transitionMs: number; // pose blend duration, 0–1000, default 0 transitionEasing: string; // CSS easing, default 'cubic-bezier(0.4, 0, 0.2, 1)' } diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index dc852233e..4e5b91970 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -94,7 +94,7 @@ interface ReactiveSpritePosition { pivot: { x: number; y: number }; // 정규화 변환 원점, 각각 0–1 idleTransform: SpriteTransform; // 일치하는 자세가 없을 때의 기본 변환 poses: SpritePose[]; // 커밋은 최대 64개까지 허용 - transitionMs: number; // 자세 전환 시간, 0–1000, 기본 90 + transitionMs: number; // 자세 전환 시간, 0–1000, 기본 0 transitionEasing: string; // CSS easing, 기본 'cubic-bezier(0.4, 0, 0.2, 1)' } diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 69d3d9740..cb2375227 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -975,7 +975,7 @@ impl SpritePose { } pub(crate) fn default_sprite_transition_ms() -> u32 { - 90 + 0 } pub(crate) fn default_sprite_transition_easing() -> String { diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 189f4d033..c94e593dc 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -6041,7 +6041,7 @@ mod tests { scale: 1.0, } ); - assert_eq!(sprite.transition_ms, 90); + assert_eq!(sprite.transition_ms, 0); assert!(!repair_sprite_numeric_ranges(&mut data)); } diff --git a/src/types/key/sprites.ts b/src/types/key/sprites.ts index 88e47b88e..ee1fd1906 100644 --- a/src/types/key/sprites.ts +++ b/src/types/key/sprites.ts @@ -205,7 +205,7 @@ export type SpritePositionsInput = Record< ReactiveSpritePositionInput[] >; -export const DEFAULT_SPRITE_TRANSITION_MS = 90; +export const DEFAULT_SPRITE_TRANSITION_MS = 0; export const DEFAULT_SPRITE_TRANSITION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'; // 같은 담당 키 집합을 가진 자세가 둘이면 해석이 모호해진다. 저장 전에 걸러낸다. diff --git a/tests/fixtures/sprite-constraint-parity.json b/tests/fixtures/sprite-constraint-parity.json index f3b1ee0ef..21008b69f 100644 --- a/tests/fixtures/sprite-constraint-parity.json +++ b/tests/fixtures/sprite-constraint-parity.json @@ -7,6 +7,6 @@ "imageRect": { "coordMin": -32768, "coordMax": 32768, "dimensionMax": 32768 }, "maxPoses": 64, "maxTriggersPerPose": 512, - "defaultTransitionMs": 90, + "defaultTransitionMs": 0, "defaultTransitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" } From 3ef5e468417b8820c7d14b9ee78ef5fc43e8869b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:02:08 +0900 Subject: [PATCH 026/125] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=8B=A8=EB=B0=9C=20=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EA=B3=BC=20=EC=86=90=EB=81=9D=20=EA=B3=A0=EC=A0=95=20=EB=8F=84?= =?UTF-8?q?=EC=9A=B0=EB=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/editor/page.mdx | 3 + docs/content/ko/api-reference/editor/page.mdx | 3 + src-tauri/src/commands/preset/load.rs | 61 ++- src-tauri/src/commands/preset/mod.rs | 12 +- src-tauri/src/models/editor.rs | 70 +++- src-tauri/src/models/mod.rs | 46 +- src-tauri/src/state/editor.rs | 124 +++++- src-tauri/src/state/migration.rs | 113 ++++- .../renderedElementContract.test.tsx | 3 + .../api/modules/editorAdapters.test.ts | 2 + src/renderer/api/modules/editorApi.test.ts | 3 + .../single/SingleSpritePanel.test.tsx | 41 +- .../single/SingleSpritePanel.tsx | 300 ++++++++++--- .../single/SpritePoseEditorPopup.tsx | 98 ++++- .../components/main/Grid/core/Grid.tsx | 3 + .../Grid/handles/SpritePoseGizmo.test.tsx | 211 ++++++++++ .../main/Grid/handles/SpritePoseGizmo.tsx | 393 ++++++++++++++++++ .../Grid/layers/SpriteItem.render.test.tsx | 5 +- .../overlay/counters/OverlaySpriteItem.tsx | 276 ++++++++++-- .../counters/overlaySpriteItem.test.tsx | 219 +++++++++- .../editor/runtime/elementOps.test.ts | 4 + .../editor/runtime/mixedElementGroups.test.ts | 2 + .../spritePatchOptimisticIntegration.test.ts | 3 + .../hooks/Grid/useGridCanvasActions.test.tsx | 2 + .../hooks/Grid/useGridCanvasActions.ts | 4 + .../hooks/Grid/useGridResize.test.tsx | 19 +- .../hooks/Grid/useGridSelection.test.tsx | 4 + .../hooks/shared/useLayoutComputation.test.ts | 2 + src/renderer/locales/en.json | 9 + src/renderer/locales/ko.json | 9 + src/renderer/locales/ru.json | 9 + src/renderer/locales/zh-Hant.json | 9 + src/renderer/locales/zh-cn.json | 9 + .../grid/useSpriteEditPreviewStore.test.ts | 1 + .../stores/grid/useSpritePoseGizmoStore.ts | 76 ++++ src/renderer/stores/signals/keySignals.ts | 43 ++ .../utils/sprite/contactSolver.test.ts | 165 ++++++++ src/renderer/utils/sprite/contactSolver.ts | 145 +++++++ .../utils/sprite/poseResolver.test.ts | 3 + src/renderer/utils/sprite/spriteReach.test.ts | 1 + .../utils/sprite/spriteWireShape.test.ts | 3 + .../overlay/App.spritePreview.test.tsx | 2 + src/renderer/windows/overlay/App.tsx | 6 +- src/types/key/sprites.test.ts | 4 + src/types/key/sprites.ts | 41 ++ tests/fixtures/sprite-constraint-parity.json | 7 +- tests/sprite-constraint-parity.test.ts | 58 +++ 47 files changed, 2473 insertions(+), 153 deletions(-) create mode 100644 src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx create mode 100644 src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx create mode 100644 src/renderer/stores/grid/useSpritePoseGizmoStore.ts create mode 100644 src/renderer/utils/sprite/contactSolver.test.ts create mode 100644 src/renderer/utils/sprite/contactSolver.ts diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index 58384c3ff..c40252a0b 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -97,6 +97,8 @@ interface ReactiveSpritePosition { pivot: { x: number; y: number }; // normalized transform origin, 0–1 each idleTransform: SpriteTransform; // base transform when no pose matches poses: SpritePose[]; // commits accept at most 64 + activation: 'whileHeld' | 'onPress'; // default whileHeld. onPress plays the pose once on key-down, then returns to idle + pressDurationMs: number; // onPress playback duration, 1–5000, default 300 transitionMs: number; // pose blend duration, 0–1000, default 0 transitionEasing: string; // CSS easing, default 'cubic-bezier(0.4, 0, 0.2, 1)' } @@ -107,6 +109,7 @@ interface SpritePose { triggers: string[]; // key element ids; commits accept at most 512, sorted and deduplicated transform: SpriteTransform; imageOverride: string | null; // replaces baseImage while this pose is active + contactPoint: { x: number; y: number }; // normalized key-contact pin of the pose image, 0–1 each, default {0.5, 1}. Editor helper only; runtime resolution ignores it } interface SpriteTransform { diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 4e5b91970..3e5d8323c 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -94,6 +94,8 @@ interface ReactiveSpritePosition { pivot: { x: number; y: number }; // 정규화 변환 원점, 각각 0–1 idleTransform: SpriteTransform; // 일치하는 자세가 없을 때의 기본 변환 poses: SpritePose[]; // 커밋은 최대 64개까지 허용 + activation: 'whileHeld' | 'onPress'; // 기본 whileHeld. onPress는 누른 순간 자세를 한 번 재생하고 idle로 복귀 + pressDurationMs: number; // onPress 재생 시간, 1–5000, 기본 300 transitionMs: number; // 자세 전환 시간, 0–1000, 기본 0 transitionEasing: string; // CSS easing, 기본 'cubic-bezier(0.4, 0, 0.2, 1)' } @@ -104,6 +106,7 @@ interface SpritePose { triggers: string[]; // 키 요소 id 목록, 커밋은 최대 512개까지 허용하며 정렬·중복 제거 transform: SpriteTransform; imageOverride: string | null; // 이 자세가 활성인 동안 baseImage를 대체 + contactPoint: { x: number; y: number }; // 자세 이미지의 키 접점(핀), 정규화 0–1, 기본 {0.5, 1}. 에디터 도우미 전용이고 런타임 해석에는 쓰이지 않습니다 } interface SpriteTransform { diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 1532242e9..451fcd993 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -1968,7 +1968,7 @@ mod tests { } #[test] - fn preset_import_ignores_stale_sprite_strategy_keys() { + fn preset_import_round_trips_sprite_activation_and_ignores_removed_match_mode() { let root = std::env::temp_dir().join(format!( "dmnote-stale-sprite-strategy-preset-{}", uuid::Uuid::new_v4() @@ -1996,7 +1996,7 @@ mod tests { .pointer_mut("/spritePositions/4key/0") .and_then(serde_json::Value::as_object_mut) .unwrap(); - sprite.insert("activation".to_string(), serde_json::json!("whileHeld")); + sprite.insert("activation".to_string(), serde_json::json!("onPress")); sprite .get_mut("poses") .and_then(serde_json::Value::as_array_mut) @@ -2013,7 +2013,7 @@ mod tests { .and_then(serde_json::Value::as_object) .unwrap(); - assert!(!sprite.contains_key("activation")); + assert_eq!(sprite["activation"], serde_json::json!("onPress")); assert!(!sprite["poses"][0] .as_object() .unwrap() @@ -2021,6 +2021,61 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn preset_import_defaults_missing_sprite_oneshot_fields() { + let root = std::env::temp_dir().join(format!( + "dmnote-missing-sprite-oneshot-fields-preset-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("preset.json"); + let sprites = SpritePositions::from([( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + )]); + let mut value = serde_json::to_value(PresetFile { + sprite_positions: Some(sprites), + ..PresetFile::default() + }) + .unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.remove("activation"); + sprite.remove("pressDurationMs"); + sprite + .get_mut("poses") + .and_then(serde_json::Value::as_array_mut) + .and_then(|poses| poses.first_mut()) + .and_then(serde_json::Value::as_object_mut) + .unwrap() + .remove("contactPoint"); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let imported = read_preset_file(&path).unwrap(); + let sprite = &imported.sprite_positions.as_ref().unwrap()["4key"][0]; + + assert_eq!( + serde_json::to_value(sprite.activation).unwrap(), + serde_json::json!("whileHeld") + ); + assert_eq!(sprite.press_duration_ms, 300); + assert_eq!( + serde_json::to_value(sprite.poses[0].contact_point).unwrap(), + serde_json::json!({ "x": 0.5, "y": 1.0 }) + ); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn committed_preset_css_paths_exclude_unrelated_store_paths() { let mut committed = AppStoreData { diff --git a/src-tauri/src/commands/preset/mod.rs b/src-tauri/src/commands/preset/mod.rs index 90e66d465..ff806f3d2 100644 --- a/src-tauri/src/commands/preset/mod.rs +++ b/src-tauri/src/commands/preset/mod.rs @@ -292,7 +292,7 @@ mod tests { } #[test] - fn preset_sprite_wire_keys_exclude_removed_strategy_fields() { + fn preset_sprite_wire_keys_include_oneshot_fields_and_exclude_removed_match_mode() { let snapshot = PresetSnapshot { sprite_positions: SpritePositions::from([( "4key".to_string(), @@ -322,6 +322,7 @@ mod tests { assert_eq!( sprite_keys, [ + "activation", "baseImage", "className", "dx", @@ -334,6 +335,7 @@ mod tests { "imageRect", "pivot", "poses", + "pressDurationMs", "transitionEasing", "transitionMs", "useInlineStyles", @@ -346,7 +348,13 @@ mod tests { pose_keys.sort_unstable(); assert_eq!( pose_keys, - ["imageOverride", "poseId", "transform", "triggers"] + [ + "contactPoint", + "imageOverride", + "poseId", + "transform", + "triggers" + ] ); } diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index a1862572c..1db3f5fd7 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -929,12 +929,15 @@ mod tests { } } - fn insert_stale_sprite_strategy_keys(value: &mut serde_json::Value, pointer: &str) { + fn insert_sprite_activation_and_removed_match_mode( + value: &mut serde_json::Value, + pointer: &str, + ) { let sprite = value .pointer_mut(pointer) .and_then(serde_json::Value::as_object_mut) .expect("sprite object exists"); - sprite.insert("activation".to_string(), serde_json::json!("whileHeld")); + sprite.insert("activation".to_string(), serde_json::json!("onPress")); sprite .get_mut("poses") .and_then(serde_json::Value::as_array_mut) @@ -945,12 +948,15 @@ mod tests { .insert("matchMode".to_string(), serde_json::json!("exact")); } - fn assert_stale_sprite_strategy_keys_omitted(value: &serde_json::Value, pointer: &str) { + fn assert_sprite_activation_round_trips_and_match_mode_is_omitted( + value: &serde_json::Value, + pointer: &str, + ) { let sprite = value .pointer(pointer) .and_then(serde_json::Value::as_object) .expect("sprite object exists"); - assert!(!sprite.contains_key("activation")); + assert_eq!(sprite["activation"], serde_json::json!("onPress")); assert!(!sprite["poses"][0] .as_object() .expect("pose object exists") @@ -958,7 +964,7 @@ mod tests { } #[test] - fn v1_editor_patch_ignores_stale_sprite_strategy_keys() { + fn v1_editor_patch_round_trips_sprite_activation_and_ignores_removed_match_mode() { let mut sprite_positions = SpritePositions::new(); sprite_positions.insert("4key".to_string(), vec![sprite_with_pose()]); let mut value = serde_json::to_value(EditorPatchV1 { @@ -966,16 +972,19 @@ mod tests { ..EditorPatchV1::default() }) .unwrap(); - insert_stale_sprite_strategy_keys(&mut value, "/spritePositions/4key/0"); + insert_sprite_activation_and_removed_match_mode(&mut value, "/spritePositions/4key/0"); let restored: EditorPatchV1 = serde_json::from_value(value).unwrap(); let serialized = serde_json::to_value(restored).unwrap(); - assert_stale_sprite_strategy_keys_omitted(&serialized, "/spritePositions/4key/0"); + assert_sprite_activation_round_trips_and_match_mode_is_omitted( + &serialized, + "/spritePositions/4key/0", + ); } #[test] - fn frozen_sprite_insert_ignores_stale_strategy_keys() { + fn frozen_sprite_insert_round_trips_activation_and_ignores_removed_match_mode() { let mut value = serde_json::to_value(EditorOpV1::InsertFrozenElements { mode: "4key".to_string(), elements: vec![EditorFrozenElementV1::Sprite { @@ -985,12 +994,53 @@ mod tests { z_updates: Vec::new(), }) .unwrap(); - insert_stale_sprite_strategy_keys(&mut value, "/elements/0/position"); + insert_sprite_activation_and_removed_match_mode(&mut value, "/elements/0/position"); let restored: EditorOpV1 = serde_json::from_value(value).unwrap(); let serialized = serde_json::to_value(restored).unwrap(); - assert_stale_sprite_strategy_keys_omitted(&serialized, "/elements/0/position"); + assert_sprite_activation_round_trips_and_match_mode_is_omitted( + &serialized, + "/elements/0/position", + ); + } + + #[test] + fn v1_editor_patch_defaults_missing_sprite_oneshot_fields() { + let mut sprite_positions = SpritePositions::new(); + sprite_positions.insert("4key".to_string(), vec![sprite_with_pose()]); + let mut value = serde_json::to_value(EditorPatchV1 { + sprite_positions: Some(sprite_positions), + ..EditorPatchV1::default() + }) + .unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.remove("activation"); + sprite.remove("pressDurationMs"); + sprite + .get_mut("poses") + .and_then(serde_json::Value::as_array_mut) + .and_then(|poses| poses.first_mut()) + .and_then(serde_json::Value::as_object_mut) + .unwrap() + .remove("contactPoint"); + + let restored: EditorPatchV1 = serde_json::from_value(value).unwrap(); + let serialized = serde_json::to_value(restored).unwrap(); + let sprite = serialized + .pointer("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object) + .unwrap(); + + assert_eq!(sprite["activation"], serde_json::json!("whileHeld")); + assert_eq!(sprite["pressDurationMs"], serde_json::json!(300)); + assert_eq!( + sprite["poses"][0]["contactPoint"], + serde_json::json!({ "x": 0.5, "y": 1.0 }) + ); } fn paint(color: &str) -> EditorPaintDescriptorV1 { diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index cb2375227..377627425 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -887,7 +887,10 @@ pub const SPRITE_TRANSFORM_ROTATION_MIN: f64 = -180.0; pub const SPRITE_TRANSFORM_ROTATION_MAX: f64 = 180.0; pub const SPRITE_TRANSFORM_SCALE_MIN: f64 = 0.1; pub const SPRITE_TRANSFORM_SCALE_MAX: f64 = 10.0; +pub const SPRITE_RESIZE_MIN_DIMENSION: f64 = 0.000_001; pub const SPRITE_TRANSITION_MS_MAX: u32 = 1_000; +pub const SPRITE_PRESS_DURATION_MS_MIN: u32 = 1; +pub const SPRITE_PRESS_DURATION_MS_MAX: u32 = 5_000; pub const MAX_SPRITE_POSES: usize = 64; pub const MAX_SPRITE_POSE_TRIGGERS: usize = 512; @@ -952,7 +955,27 @@ pub enum SpriteImageFit { Fill, } -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SpriteActivation { + #[default] + WhileHeld, + OnPress, +} + +pub(crate) fn default_sprite_activation() -> SpriteActivation { + SpriteActivation::WhileHeld +} + +pub(crate) fn default_sprite_press_duration_ms() -> u32 { + 300 +} + +pub(crate) fn default_sprite_contact_point() -> SpriteAnchor { + SpriteAnchor { x: 0.5, y: 1.0 } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SpritePose { #[serde(default, skip_serializing_if = "String::is_empty")] @@ -963,6 +986,21 @@ pub struct SpritePose { pub transform: SpriteTransform, #[serde(default)] pub image_override: Option, + #[serde(default = "default_sprite_contact_point")] + pub contact_point: SpriteAnchor, +} + +impl Default for SpritePose { + fn default() -> Self { + Self { + pose_id: String::new(), + name: None, + triggers: Vec::new(), + transform: SpriteTransform::default(), + image_override: None, + contact_point: default_sprite_contact_point(), + } + } } impl SpritePose { @@ -1012,6 +1050,10 @@ pub struct ReactiveSpritePosition { pub idle_transform: SpriteTransform, #[serde(default)] pub poses: Vec, + #[serde(default = "default_sprite_activation")] + pub activation: SpriteActivation, + #[serde(default = "default_sprite_press_duration_ms")] + pub press_duration_ms: u32, #[serde(default = "default_sprite_transition_ms")] pub transition_ms: u32, #[serde(default = "default_sprite_transition_easing")] @@ -1038,6 +1080,8 @@ impl Default for ReactiveSpritePosition { pivot: SpriteAnchor::default(), idle_transform: SpriteTransform::default(), poses: Vec::new(), + activation: default_sprite_activation(), + press_duration_ms: default_sprite_press_duration_ms(), transition_ms: default_sprite_transition_ms(), transition_easing: default_sprite_transition_easing(), } diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index b2ad1789a..ce9b6eb43 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -13,9 +13,10 @@ use crate::{ EditorElementTypeV1, EditorField, ElementShadowSpec, GraphPosition, KeyCounters, KeyMappings, KeyPosition, KeySlot, KnobPosition, ReactiveSpritePosition, SpriteTransform, StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, - MAX_SPRITE_POSES, MAX_SPRITE_POSE_TRIGGERS, SPRITE_TRANSFORM_OFFSET_MAX, - SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN, - SPRITE_TRANSFORM_SCALE_MAX, SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX, + MAX_SPRITE_POSES, MAX_SPRITE_POSE_TRIGGERS, SPRITE_PRESS_DURATION_MS_MAX, + SPRITE_PRESS_DURATION_MS_MIN, SPRITE_TRANSFORM_OFFSET_MAX, SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN, SPRITE_TRANSFORM_SCALE_MAX, + SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX, }, }; @@ -1658,6 +1659,26 @@ fn collect_sprite_violations( )); } + if !(SPRITE_PRESS_DURATION_MS_MIN..=SPRITE_PRESS_DURATION_MS_MAX) + .contains(&sprite.press_duration_ms) + { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_PRESS_DURATION", + ViolationPropertyPath::SpriteProperty { + section: "activation", + property: "pressDurationMs", + }, + InvalidValueSignature::Count(sprite.press_duration_ms as usize), + ), + format!( + "spritePositions {mode}[{sprite_index}].pressDurationMs must be between {SPRITE_PRESS_DURATION_MS_MIN} and {SPRITE_PRESS_DURATION_MS_MAX}" + ), + )); + } + let mut trigger_sets = HashSet::new(); for (pose_index, pose) in sprite.poses.iter().enumerate() { collect_sprite_transform_violations( @@ -1669,6 +1690,26 @@ fn collect_sprite_violations( &pose.transform, violations, ); + for (property, value) in [("x", pose.contact_point.x), ("y", pose.contact_point.y)] + { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + violations.insert(ValidationViolation::new( + native_violation_key( + NativeElementKind::Sprite, + &sprite.id, + "INVALID_SPRITE_CONTACT_POINT", + ViolationPropertyPath::SpritePoseProperty { + pose_index, + property, + }, + InvalidValueSignature::FloatBits(value.to_bits()), + ), + format!( + "spritePositions {mode}[{sprite_index}].poses[{pose_index}].contactPoint.{property} must be between 0 and 1" + ), + )); + } + } if pose.triggers.len() > MAX_SPRITE_POSE_TRIGGERS { violations.insert(ValidationViolation::new( native_violation_key( @@ -2761,7 +2802,8 @@ mod tests { EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpResultStatusV1, EditorOpResultV1, EditorOpV1, EditorPatchV1, EditorTargetGroupV1, EditorZUpdateV1, ElementShadowSpec, GraphPosition, GraphStatType, GraphType, KeyPosition, KnobPosition, LayerGroupDef, - ReactiveSpritePosition, SpritePose, SpriteRect, StatPosition, StatType, + ReactiveSpritePosition, SpriteActivation, SpriteAnchor, SpritePose, SpriteRect, + StatPosition, StatType, }; use super::*; @@ -2777,11 +2819,15 @@ mod tests { scale: NumericRangeFixture, anchor: NumericRangeFixture, transition_ms: IntegerRangeFixture, + press_duration_ms: IntegerRangeFixture, image_rect: ImageRectRangeFixture, max_poses: usize, max_triggers_per_pose: usize, default_transition_ms: u32, default_transition_easing: String, + default_activation: SpriteActivation, + default_press_duration_ms: u32, + default_contact_point: SpriteAnchor, } #[derive(serde::Deserialize)] @@ -6367,6 +6413,45 @@ mod tests { assert!(violations.contains("INVALID_SPRITE_PIVOT")); } + for contact_point in [ + SpriteAnchor { + x: fixture.anchor.min, + y: fixture.anchor.max, + }, + SpriteAnchor { + x: fixture.anchor.max, + y: fixture.anchor.min, + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + poses: vec![SpritePose { + contact_point, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(!violations.contains("INVALID_SPRITE_CONTACT_POINT")); + } + for contact_point in [ + SpriteAnchor { + x: fixture.anchor.min - 0.000_001, + y: fixture.anchor.min, + }, + SpriteAnchor { + x: fixture.anchor.max, + y: fixture.anchor.max + 0.000_001, + }, + ] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + poses: vec![SpritePose { + contact_point, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }); + assert!(violations.contains("INVALID_SPRITE_CONTACT_POINT")); + } + let at_limit = SpriteRect { x: fixture.image_rect.coord_min, y: fixture.image_rect.coord_max, @@ -6449,6 +6534,27 @@ mod tests { ..ReactiveSpritePosition::default() }) .contains("INVALID_SPRITE_TRANSITION")); + + assert_eq!(fixture.press_duration_ms.min, SPRITE_PRESS_DURATION_MS_MIN); + assert_eq!(fixture.press_duration_ms.max, SPRITE_PRESS_DURATION_MS_MAX); + for press_duration_ms in [fixture.press_duration_ms.min, fixture.press_duration_ms.max] { + let violations = sprite_violation_codes(ReactiveSpritePosition { + press_duration_ms, + ..ReactiveSpritePosition::default() + }); + assert!(!violations.contains("INVALID_SPRITE_PRESS_DURATION")); + } + for press_duration_ms in [ + fixture.press_duration_ms.min - 1, + fixture.press_duration_ms.max + 1, + ] { + assert!(sprite_violation_codes(ReactiveSpritePosition { + press_duration_ms, + ..ReactiveSpritePosition::default() + }) + .contains("INVALID_SPRITE_PRESS_DURATION")); + } + assert_eq!( crate::models::default_sprite_transition_ms(), fixture.default_transition_ms @@ -6457,6 +6563,16 @@ mod tests { crate::models::default_sprite_transition_easing(), fixture.default_transition_easing ); + let default_sprite = ReactiveSpritePosition::default(); + assert_eq!(default_sprite.activation, fixture.default_activation); + assert_eq!( + default_sprite.press_duration_ms, + fixture.default_press_duration_ms + ); + assert_eq!( + SpritePose::default().contact_point, + fixture.default_contact_point + ); } #[test] diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index c94e593dc..911592ac3 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -25,20 +25,21 @@ use crate::{ }, defaults::{default_keys, default_positions}, models::{ - default_missing_note_gradient_multipliers, default_sprite_transition_ms, - normalize_key_slot, scrub_removed_text_outline_fields, AppStoreData, - CounterAnimationPreset, CustomCss, CustomCssHistoryEntry, CustomFont, CustomJs, CustomTab, - FontType, FontWeightRange, GradientSpec, GraphPosition, GraphPositions, GraphStatType, - GraphType, GridSettings, ImageMode, ImageTransform, JsPlugin, KeyCounters, KeyMappings, - KeyPosition, KeyPositions, KeySlot, KnobPosition, KnobPositions, LayerGroupDef, - LayerGroups, NoteSettings, ReactiveSpritePosition, ShortcutsState, SoundLibraryEntry, - SpriteAnchor, SpritePositions, SpriteRect, SpriteTransform, StatPosition, StatPositions, - StatType, StoredOverlayBounds, TabCss, TabNoteSettings, IMAGE_TRANSFORM_OFFSET_MAX, - IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, - IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, - SPRITE_TRANSFORM_OFFSET_MAX, SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_ROTATION_MAX, - SPRITE_TRANSFORM_ROTATION_MIN, SPRITE_TRANSFORM_SCALE_MAX, SPRITE_TRANSFORM_SCALE_MIN, - SPRITE_TRANSITION_MS_MAX, + default_missing_note_gradient_multipliers, default_sprite_contact_point, + default_sprite_press_duration_ms, default_sprite_transition_ms, normalize_key_slot, + scrub_removed_text_outline_fields, AppStoreData, CounterAnimationPreset, CustomCss, + CustomCssHistoryEntry, CustomFont, CustomJs, CustomTab, FontType, FontWeightRange, + GradientSpec, GraphPosition, GraphPositions, GraphStatType, GraphType, GridSettings, + ImageMode, ImageTransform, JsPlugin, KeyCounters, KeyMappings, KeyPosition, KeyPositions, + KeySlot, KnobPosition, KnobPositions, LayerGroupDef, LayerGroups, NoteSettings, + ReactiveSpritePosition, ShortcutsState, SoundLibraryEntry, SpriteAnchor, SpritePositions, + SpriteRect, SpriteTransform, StatPosition, StatPositions, StatType, StoredOverlayBounds, + TabCss, TabNoteSettings, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, + IMAGE_TRANSFORM_SCALE_MIN, POSITION_COLLECTION_FIELDS, SPRITE_PRESS_DURATION_MS_MAX, + SPRITE_PRESS_DURATION_MS_MIN, SPRITE_TRANSFORM_OFFSET_MAX, SPRITE_TRANSFORM_OFFSET_MIN, + SPRITE_TRANSFORM_ROTATION_MAX, SPRITE_TRANSFORM_ROTATION_MIN, SPRITE_TRANSFORM_SCALE_MAX, + SPRITE_TRANSFORM_SCALE_MIN, SPRITE_TRANSITION_MS_MAX, }, services::font_metadata::parse_font_metadata, }; @@ -1040,8 +1041,11 @@ fn repair_sprite_numeric_ranges(data: &mut AppStoreData) -> bool { const IMAGE_RECT_COORDINATE_MAX: f64 = MAX_ABS_COORDINATE; const IMAGE_RECT_DIMENSION_MAX: f64 = MAX_DIMENSION; - fn repair_bounded(value: &mut f64, fallback: f64, minimum: f64, maximum: f64) -> bool { - if value.is_finite() && (minimum..=maximum).contains(value) { + fn repair_bounded(value: &mut T, fallback: T, minimum: T, maximum: T) -> bool + where + T: Copy + PartialOrd, + { + if (minimum..=maximum).contains(value) { return false; } *value = fallback; @@ -1091,8 +1095,7 @@ fn repair_sprite_numeric_ranges(data: &mut AppStoreData) -> bool { ) } - fn repair_anchor(anchor: &mut SpriteAnchor) -> bool { - let fallback = SpriteAnchor::default(); + fn repair_anchor(anchor: &mut SpriteAnchor, fallback: SpriteAnchor) -> bool { repair_bounded(&mut anchor.x, fallback.x, 0.0, 1.0) | repair_bounded(&mut anchor.y, fallback.y, 0.0, 1.0) } @@ -1127,14 +1130,21 @@ fn repair_sprite_numeric_ranges(data: &mut AppStoreData) -> bool { | repair_positive(&mut position.width, fallback.width, None) | repair_positive(&mut position.height, fallback.height, None) | repair_rect(&mut position.image_rect) - | repair_anchor(&mut position.pivot) - | repair_transform(&mut position.idle_transform); + | repair_anchor(&mut position.pivot, SpriteAnchor::default()) + | repair_transform(&mut position.idle_transform) + | repair_bounded( + &mut position.press_duration_ms, + default_sprite_press_duration_ms(), + SPRITE_PRESS_DURATION_MS_MIN, + SPRITE_PRESS_DURATION_MS_MAX, + ); if position.transition_ms > SPRITE_TRANSITION_MS_MAX { position.transition_ms = default_sprite_transition_ms(); repaired = true; } for pose in &mut position.poses { repaired |= repair_transform(&mut pose.transform); + repaired |= repair_anchor(&mut pose.contact_point, default_sprite_contact_point()); } repaired } @@ -5994,6 +6004,7 @@ mod tests { poses: vec![SpritePose { pose_id: uuid::Uuid::new_v4().to_string(), triggers: original_triggers.clone(), + contact_point: SpriteAnchor { x: -0.1, y: 1.1 }, transform: SpriteTransform { x: 2_001.0, y: -18.0, @@ -6002,6 +6013,7 @@ mod tests { }, ..SpritePose::default() }], + press_duration_ms: 5_001, transition_ms: 1_001, ..ReactiveSpritePosition::default() }], @@ -6032,6 +6044,10 @@ mod tests { ); assert_eq!(sprite.poses.len(), 1); assert_eq!(sprite.poses[0].triggers, original_triggers); + assert_eq!( + sprite.poses[0].contact_point, + SpriteAnchor { x: 0.5, y: 1.0 } + ); assert_eq!( sprite.poses[0].transform, SpriteTransform { @@ -6041,6 +6057,7 @@ mod tests { scale: 1.0, } ); + assert_eq!(sprite.press_duration_ms, 300); assert_eq!(sprite.transition_ms, 0); assert!(!repair_sprite_numeric_ranges(&mut data)); } @@ -6175,7 +6192,7 @@ mod tests { } #[test] - fn store_load_ignores_stale_sprite_strategy_keys() { + fn store_load_round_trips_sprite_activation_and_ignores_removed_match_mode() { let path = std::env::temp_dir().join(format!( "dmnote-stale-sprite-strategy-store-{}.json", uuid::Uuid::new_v4() @@ -6198,7 +6215,7 @@ mod tests { .pointer_mut("/spritePositions/4key/0") .and_then(serde_json::Value::as_object_mut) .unwrap(); - sprite.insert("activation".to_string(), serde_json::json!("whileHeld")); + sprite.insert("activation".to_string(), serde_json::json!("onPress")); sprite .get_mut("poses") .and_then(serde_json::Value::as_array_mut) @@ -6216,13 +6233,63 @@ mod tests { .and_then(serde_json::Value::as_object) .unwrap(); - assert!(!sprite.contains_key("activation")); + assert_eq!(sprite["activation"], serde_json::json!("onPress")); assert!(!sprite["poses"][0] .as_object() .unwrap() .contains_key("matchMode")); } + #[test] + fn store_load_defaults_missing_sprite_activation_duration_and_contact_point() { + let path = std::env::temp_dir().join(format!( + "dmnote-missing-sprite-oneshot-fields-store-{}.json", + uuid::Uuid::new_v4() + )); + let mut data = store_with_each_native_collection(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + let mut value = serde_json::to_value(data).unwrap(); + let sprite = value + .pointer_mut("/spritePositions/4key/0") + .and_then(serde_json::Value::as_object_mut) + .unwrap(); + sprite.remove("activation"); + sprite.remove("pressDurationMs"); + sprite + .get_mut("poses") + .and_then(serde_json::Value::as_array_mut) + .and_then(|poses| poses.first_mut()) + .and_then(serde_json::Value::as_object_mut) + .unwrap() + .remove("contactPoint"); + std::fs::write(&path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); + + let loaded = load_store_from_path(&path).unwrap(); + let _ = std::fs::remove_file(path); + let sprite = &loaded.data.sprite_positions["4key"][0]; + + assert_eq!( + serde_json::to_value(sprite.activation).unwrap(), + serde_json::json!("whileHeld") + ); + assert_eq!(sprite.press_duration_ms, 300); + assert_eq!( + sprite.poses[0].contact_point, + SpriteAnchor { x: 0.5, y: 1.0 } + ); + } + #[test] fn image_settings_recover_in_place_without_removing_positions() { let path = std::env::temp_dir().join(format!( diff --git a/src/renderer/__tests__/renderedElementContract.test.tsx b/src/renderer/__tests__/renderedElementContract.test.tsx index 7ba98808c..6a0f5a860 100644 --- a/src/renderer/__tests__/renderedElementContract.test.tsx +++ b/src/renderer/__tests__/renderedElementContract.test.tsx @@ -345,6 +345,8 @@ describe('렌더 DOM 계약', () => { root.render( { idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-contract', triggers: ['el-contract'], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts index c7dc02e8b..6f4c7c68a 100644 --- a/src/renderer/api/modules/editorAdapters.test.ts +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -40,6 +40,8 @@ const document: EditorDocumentV1 = { // 백엔드 실물 wire 형태: layerName·groupId는 None이면 직렬화에서 생략된다 const spriteFixture = (id: string): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id, dx: 0, dy: 0, diff --git a/src/renderer/api/modules/editorApi.test.ts b/src/renderer/api/modules/editorApi.test.ts index a4d3291ca..7a5817620 100644 --- a/src/renderer/api/modules/editorApi.test.ts +++ b/src/renderer/api/modules/editorApi.test.ts @@ -1345,6 +1345,8 @@ describe('canonical 검증의 스프라이트 poseId 네임스페이스', () => const sprite = ( overrides: Partial = {}, ): ReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: SPRITE_ID, dx: 0, dy: 0, @@ -1361,6 +1363,7 @@ describe('canonical 검증의 스프라이트 poseId 네임스페이스', () => idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: POSE_ID, triggers: [], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index 81599197e..5de11abfc 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -104,6 +104,8 @@ const OTHER_SPRITE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const spritePosition = ( overrides: Partial = {}, ): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: SPRITE_ID, dx: 0, dy: 0, @@ -215,13 +217,16 @@ describe('SingleSpritePanel 자세 편집', () => { `[data-testid="pose-popup"][aria-label="${label}"]`, ); - // 담당 키 드롭다운 트리거는 요약 텍스트로 찾는다 + // 담당 키 드롭다운은 자세 팝업(dialog) 안에서만 찾는다 - 패널의 반응 방식 + // 드롭다운('spriteActivationHold'의 A)이 부분 일치에 걸리는 것을 차단 const triggerDropdownByText = (text: string) => [ ...container.querySelectorAll( 'button[aria-haspopup="listbox"]', ), - ].find((button) => button.textContent?.includes(text))!; + ] + .filter((button) => button.closest('[data-testid="pose-popup"]')) + .find((button) => button.textContent?.includes(text))!; const openListbox = () => document.querySelector( @@ -313,6 +318,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, }, ]); }); @@ -321,6 +327,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -355,6 +362,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -394,6 +402,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -408,7 +417,7 @@ describe('SingleSpritePanel 자세 편집', () => { expect(posePopup()!.getAttribute('aria-label')).toBe( 'propertiesPanel.spritePose 1', ); - expect(posePopup()!.dataset.fallbackHeight).toBe('181'); + expect(posePopup()!.dataset.fallbackHeight).toBe('297'); // 같은 행 재클릭 - 토글 닫힘 act(() => poseEditButtons()[0].click()); @@ -419,12 +428,14 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, }, { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-2', triggers: [KEY_ID_B], transform: { x: 4, y: 0, rotation: 0, scale: 1 }, @@ -472,12 +483,14 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, }, { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-2', triggers: [KEY_ID_B], transform: { x: 4, y: 0, rotation: 0, scale: 1 }, @@ -512,6 +525,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', name: '왼손', triggers: [KEY_ID_A], @@ -519,6 +533,7 @@ describe('SingleSpritePanel 자세 편집', () => { imageOverride: 'override.png', }, { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-2', triggers: [KEY_ID_B], transform: { x: 4, y: 0, rotation: 0, scale: 1 }, @@ -568,6 +583,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [KEY_ID_C], transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, imageOverride: 'override.png', + contactPoint: { x: 0.5, y: 1 }, }, // 구조 변경 시 무명 상태는 보이던 번호가 이름으로 고정된다 { ...position.poses[1], name: 'propertiesPanel.spritePose 1' }, @@ -579,6 +595,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', name: '왼손 common.copySuffix', triggers: [KEY_ID_A], @@ -609,6 +626,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [key], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, }); const position = spritePosition({ poses: [ @@ -647,6 +665,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [`key-${index}`], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, })); const position = spritePosition({ poses }); seed(position); @@ -686,6 +705,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', name: 'propertiesPanel.spritePose 1', triggers: [KEY_ID_A], @@ -715,6 +735,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [key], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, }); const position = spritePosition({ poses: [ @@ -749,6 +770,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', name: 'propertiesPanel.spritePose 1', triggers: [KEY_ID_A], @@ -756,6 +778,7 @@ describe('SingleSpritePanel 자세 편집', () => { imageOverride: null, }, { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-2', triggers: [KEY_ID_B], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -785,6 +808,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [`key-${index}`], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, })); const position = spritePosition({ poses }); seed(position); @@ -806,6 +830,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [KEY_ID_A], transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, }; const position = spritePosition({ poses: [basePose] }); seed(position); @@ -829,6 +854,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -863,6 +889,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -891,6 +918,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -924,6 +952,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A, KEY_ID_B], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -951,6 +980,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A, deadId], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -980,6 +1010,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -1071,6 +1102,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -1128,6 +1160,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -1177,6 +1210,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -1289,6 +1323,7 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index 54f6cdcfd..9063ff2ec 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -12,13 +12,23 @@ import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import { slotDisplayName } from '@utils/keySlot'; import { ACTION_BUTTON_CLASS, AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; +import { useSpritePoseGizmoStore } from '@stores/grid/useSpritePoseGizmoStore'; import { + contactWorldPosition, + solveTranslationKeepingContact, + type ContactGeometry, +} from '@utils/sprite/contactSolver'; +import { + DEFAULT_SPRITE_CONTACT_POINT, DEFAULT_SPRITE_TRANSITION_EASING, IDENTITY_SPRITE_TRANSFORM, SPRITE_CONSTRAINTS, findDuplicateTriggerPose, type ReactiveSpritePosition, + type SpriteActivation, + type SpriteAnchor, type SpritePose, + type SpriteTransform, } from '@src/types/key/sprites'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import { PANEL_ROOT_CLASS, PANEL_HEADER_CLASS } from '../panelChrome'; @@ -125,6 +135,11 @@ export const SingleSpritePanel: React.FC = ({ const [editorTarget, setEditorTarget] = useState( null, ); + // 도우미 토글 - 저장하지 않는 편집 세션 상태. + // 핀 고정: 회전·배율 스크럽이 손끝을 제자리에 두도록 x·y 역산 + // 뻗기: 캔버스 노브 드래그가 scale까지 역산 + const [pinLockEnabled, setPinLockEnabled] = useState(false); + const [stretchEnabled, setStretchEnabled] = useState(false); // 무효 자세(빈/중복 트리거)는 백엔드가 거부하므로 커밋 착지 전까지 패널이 값을 들고 있는다 const [posesDraft, setPosesDraft] = useState<{ @@ -140,6 +155,8 @@ export const SingleSpritePanel: React.FC = ({ setLastPanelTargetKey(panelTargetKey); setEditorTarget(null); setPosesDraft(null); + setPinLockEnabled(false); + setStretchEnabled(false); } // draft는 커밋이 canonical에 착지하면 지운다 - position prop이 곧 canonical @@ -303,6 +320,124 @@ export const SingleSpritePanel: React.FC = ({ ), ); + const poseGeometry = (pose: SpritePose): ContactGeometry => ({ + imageRect: position.imageRect, + pivot: position.pivot, + contactPoint: pose.contactPoint, + }); + + // 핀 고정 보정 - 회전·배율 변경에만 적용한다. x·y 직접 입력은 위치 지정 의도라 + // 건드리지 않고, 퇴화(핀=축)면 원값 그대로 둔다 + const withPinLock = (next: SpriteTransform): SpriteTransform => { + if (!pinLockEnabled || !editingPose) return next; + const base = editingPose.transform; + if (next.rotation === base.rotation && next.scale === base.scale) { + return next; + } + const geometry = poseGeometry(editingPose); + const solved = solveTranslationKeepingContact( + geometry, + { rotation: next.rotation, scale: next.scale }, + contactWorldPosition(geometry, base), + ); + return solved.status === 'ok' ? solved.transform : next; + }; + + // 자세 필드 실시간 프리뷰 - 커밋 가능하면 gesture 채널, 무효 draft면 캔버스 스냅샷 + const previewPosePatch = (patch: Partial) => { + if (posesCommittable) { + previewFields({ + poses: displayPoses.map((entry, index) => + index === editingPoseIndex ? { ...entry, ...patch } : entry, + ), + }); + return; + } + if (!editingPose) return; + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: position.id, + poseId: editingPose.poseId, + fallbackPose: { ...editingPose, ...patch }, + preferFallback: true, + }); + }; + + const handleTransformPreview = (next: SpriteTransform) => + previewPosePatch({ transform: withPinLock(next) }); + + const handleTransformCommit = (next: SpriteTransform) => + replacePose(editingPoseIndex, { transform: withPinLock(next) }); + + const handleTransformCancel = () => { + editGestureController.cancel(); + // 무효 draft 스크럽은 스냅샷 채널이라 gesture 취소가 복원하지 못한다 + if (!posesCommittable && editingPose) { + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: position.id, + poseId: editingPose.poseId, + fallbackPose: editingPose, + preferFallback: true, + }); + } + }; + + // 기즈모 콜백은 ref 경유 - 세션 재발행 없이 항상 최신 렌더의 배선을 쓴다 + const gizmoHandlersRef = useRef<{ + preview: (next: SpriteTransform) => void; + commit: (next: SpriteTransform) => void; + cancel: () => void; + commitContactPoint: (point: SpriteAnchor) => void; + } | null>(null); + useEffect(() => { + gizmoHandlersRef.current = { + // 노브 드래그는 축 고정 역산이 x·y까지 이미 정했다 - 핀 고정 보정 미적용 + preview: (next) => previewPosePatch({ transform: next }), + commit: (next) => replacePose(editingPoseIndex, { transform: next }), + cancel: handleTransformCancel, + commitContactPoint: (point) => + replacePose(editingPoseIndex, { contactPoint: point }), + }; + }); + + // 자세 팝업이 열려 있는 동안 캔버스 기즈모 세션 발행 + useLayoutEffect(() => { + const store = useSpritePoseGizmoStore.getState(); + if (activeEditorTarget?.kind === 'pose' && editingPose) { + store.setSession({ + positionId: position.id, + poseId: editingPose.poseId, + origin: { dx: position.dx, dy: position.dy }, + imageRect: position.imageRect, + pivot: position.pivot, + contactPoint: editingPose.contactPoint, + transform: editingPose.transform, + stretch: stretchEnabled, + preview: (next) => gizmoHandlersRef.current?.preview(next), + commit: (next) => gizmoHandlersRef.current?.commit(next), + cancel: () => gizmoHandlersRef.current?.cancel(), + commitContactPoint: (point) => + gizmoHandlersRef.current?.commitContactPoint(point), + }); + } else { + store.setSession(null); + } + }, [ + activeEditorTarget, + editingPose, + position.id, + position.dx, + position.dy, + position.imageRect, + position.pivot, + stretchEnabled, + ]); + useLayoutEffect( + () => () => useSpritePoseGizmoStore.getState().setSession(null), + [], + ); + const togglePoseTrigger = (poseIndex: number, keyId: string) => { const pose = displayPoses[poseIndex]; if (!pose) return; @@ -346,6 +481,10 @@ export const SingleSpritePanel: React.FC = ({ // 새 상태는 항등에서 출발 - 기본 배치와의 차이만 상태가 가진다 transform: { ...IDENTITY_SPRITE_TRANSFORM }, imageOverride: null, + // 핀은 이전 상태를 따라간다 - 정렬된 이미지 세트에서 재지정 반복 방지 + contactPoint: { + ...(materialized.at(-1)?.contactPoint ?? DEFAULT_SPRITE_CONTACT_POINT), + }, }; updatePoses([...materialized, pose]); // 새 상태는 담당 키 선택이 다음 단계라 편집 팝업을 바로 연다. @@ -493,6 +632,7 @@ export const SingleSpritePanel: React.FC = ({ triggers: [], transform: IDENTITY_SPRITE_TRANSFORM, imageOverride: null, + contactPoint: DEFAULT_SPRITE_CONTACT_POINT, }, ])[poses.length]; @@ -626,7 +766,7 @@ export const SingleSpritePanel: React.FC = ({ }; const spriteTitle = position.layerName || 'Sprite'; - const { anchor, transitionMs } = SPRITE_CONSTRAINTS; + const { anchor, transitionMs, pressDurationMs } = SPRITE_CONSTRAINTS; const pivotPercent = (value: number) => Math.round(value * 1000) / 10; const easingValue = position.transitionEasing || DEFAULT_SPRITE_TRANSITION_EASING; @@ -936,34 +1076,98 @@ export const SingleSpritePanel: React.FC = ({ ) : null}
- {/* 전환 */} + {/* 전환·반응 */} - - commitFields({ - // Rust u32 계약 - 스크럽 소수값을 정수로 고정 - transitionMs: Math.round( - clamp(value, transitionMs.min, transitionMs.max), - ), - }) + commitFields({ activation: value as SpriteActivation }) } - onPreview={(value) => - previewFields({ - transitionMs: Math.round( - clamp(value, transitionMs.min, transitionMs.max), - ), - }) - } - onCancel={() => editGestureController.cancel()} - suffix="ms" - min={transitionMs.min} - max={transitionMs.max} + align="right" /> + {position.activation === 'onPress' ? ( + + + commitFields({ + // Rust u32 계약 - 스크럽 소수값을 정수로 고정 + pressDurationMs: Math.round( + clamp( + value, + pressDurationMs.min, + pressDurationMs.max, + ), + ), + }) + } + onPreview={(value) => + previewFields({ + pressDurationMs: Math.round( + clamp( + value, + pressDurationMs.min, + pressDurationMs.max, + ), + ), + }) + } + onCancel={() => editGestureController.cancel()} + suffix="ms" + min={pressDurationMs.min} + max={pressDurationMs.max} + /> + + ) : ( + + + commitFields({ + // Rust u32 계약 - 스크럽 소수값을 정수로 고정 + transitionMs: Math.round( + clamp(value, transitionMs.min, transitionMs.max), + ), + }) + } + onPreview={(value) => + previewFields({ + transitionMs: Math.round( + clamp(value, transitionMs.min, transitionMs.max), + ), + }) + } + onCancel={() => editGestureController.cancel()} + suffix="ms" + min={transitionMs.min} + max={transitionMs.max} + /> + + )} = ({ commitFields({ transitionEasing: value }) } align="right" - widthClass="w-[110px]" /> @@ -1021,43 +1224,20 @@ export const SingleSpritePanel: React.FC = ({ onImageReset: () => replacePose(editingPoseIndex, { imageOverride: null }), }} - onTransformCommit={(next) => - replacePose(editingPoseIndex, { transform: next }) - } - onTransformPreview={(next) => { - // 콜백 부재는 접두 스크럽 자체를 꺼 버린다 - 항상 넘기고 안에서 분기 - if (posesCommittable) { - previewFields({ - poses: displayPoses.map((entry, index) => - index === editingPoseIndex - ? { ...entry, transform: next } - : entry, - ), - }); - return; - } - // 무효 draft는 커밋 채널에 못 실리므로 캔버스 스냅샷을 직접 갱신 - if (!editingPose) return; - useSpriteEditPreviewStore.getState().publish({ - kind: 'pose', - positionId: position.id, - poseId: editingPose.poseId, - fallbackPose: { ...editingPose, transform: next }, - preferFallback: true, - }); - }} - onTransformCancel={() => { - editGestureController.cancel(); - // 무효 draft 스크럽은 스냅샷 채널이라 gesture 취소가 복원하지 못한다 - if (!posesCommittable && editingPose) { - useSpriteEditPreviewStore.getState().publish({ - kind: 'pose', - positionId: position.id, - poseId: editingPose.poseId, - fallbackPose: editingPose, - preferFallback: true, - }); - } + // 콜백 부재는 접두 스크럽 자체를 꺼 버린다 - 항상 넘기고 안에서 분기 + onTransformCommit={handleTransformCommit} + onTransformPreview={handleTransformPreview} + onTransformCancel={handleTransformCancel} + pinControls={{ + contactPoint: editingPose.contactPoint, + pinLock: pinLockEnabled, + stretch: stretchEnabled, + onContactPointCommit: (point) => + replacePose(editingPoseIndex, { contactPoint: point }), + onContactPointPreview: (point) => + previewPosePatch({ contactPoint: point }), + onPinLockToggle: () => setPinLockEnabled((value) => !value), + onStretchToggle: () => setStretchEnabled((value) => !value), }} onClose={() => setEditorTarget(null)} t={t} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx index 09a30bb5c..def44126b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -1,5 +1,6 @@ import React from 'react'; import PickerSurface from '@components/main/Grid/PropertiesPanel/PickerSurface'; +import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; import { NumberInput } from '@components/main/common/NumberInput'; import { @@ -8,6 +9,7 @@ import { } from '@components/main/common/TransformGlyphs'; import { SPRITE_CONSTRAINTS, + type SpriteAnchor, type SpriteImageFit, type SpriteTransform, } from '@src/types/key/sprites'; @@ -29,6 +31,19 @@ export interface SpritePoseControls { onImageReset: () => void; } +// 손끝 핀 컨트롤 묶음 - 값은 캔버스 노브(Alt 드래그)와 같은 contactPoint +export interface SpritePosePinControls { + contactPoint: SpriteAnchor; + /** 회전·배율 스크럽이 손끝을 제자리에 두도록 x·y를 역산 */ + pinLock: boolean; + /** 캔버스 노브 드래그가 scale까지 역산 */ + stretch: boolean; + onContactPointCommit: (point: SpriteAnchor) => void; + onContactPointPreview: (point: SpriteAnchor) => void; + onPinLockToggle: () => void; + onStretchToggle: () => void; +} + interface SpritePoseEditorPopupProps { open: boolean; ariaLabel: string; @@ -38,6 +53,7 @@ interface SpritePoseEditorPopupProps { referenceRef: React.RefObject; panelElement: HTMLElement | null; poseControls: SpritePoseControls; + pinControls: SpritePosePinControls; // 행 전환 시 바깥닫힘을 거치지 않는 영역 (상태 목록 well) interactiveRefs?: React.RefObject[]; onTransformCommit: (next: SpriteTransform) => void; @@ -59,6 +75,7 @@ const SpritePoseEditorPopup: React.FC = ({ referenceRef, panelElement, poseControls, + pinControls, interactiveRefs, onTransformCommit, onTransformPreview, @@ -88,7 +105,7 @@ const SpritePoseEditorPopup: React.FC = ({ referenceRef={referenceRef} panelElement={panelElement} fallbackWidth={172} - fallbackHeight={181} + fallbackHeight={297} cardClassName="flex flex-col p-[8px] gap-[8px] w-[172px] rounded-popup" offsetY={-93} interactiveRefs={interactiveRefs} @@ -201,6 +218,85 @@ const SpritePoseEditorPopup: React.FC = ({
+ {/* 핀(손끝) - 자세 이미지 기준 %. 캔버스 노브·Alt 드래그와 같은 값 */} +
+ + {t('propertiesPanel.spriteContactPoint') || '손끝'} + +
+ + pinControls.onContactPointCommit({ + ...pinControls.contactPoint, + x: clamp(value, 0, 100) / 100, + }) + } + onPreview={(value) => + pinControls.onContactPointPreview({ + ...pinControls.contactPoint, + x: clamp(value, 0, 100) / 100, + }) + } + onCancel={onTransformCancel} + prefix="X" + suffix="%" + ariaLabel={`${ + t('propertiesPanel.spriteContactPoint') || '손끝' + } X`} + width="100%" + min={0} + max={100} + allowDecimal + decimalScale={1} + /> + + pinControls.onContactPointCommit({ + ...pinControls.contactPoint, + y: clamp(value, 0, 100) / 100, + }) + } + onPreview={(value) => + pinControls.onContactPointPreview({ + ...pinControls.contactPoint, + y: clamp(value, 0, 100) / 100, + }) + } + onCancel={onTransformCancel} + prefix="Y" + suffix="%" + ariaLabel={`${ + t('propertiesPanel.spriteContactPoint') || '손끝' + } Y`} + width="100%" + min={0} + max={100} + allowDecimal + decimalScale={1} + /> +
+
+ + {t('propertiesPanel.spritePinLock') || '핀 고정'} + + +
+
+ + {t('propertiesPanel.spriteStretch') || '뻗기'} + + +
+
+ {/* 담당 키 - 미리보기·변환 아래가 정위치, 신규 상태도 카드가 작아 한눈에 보인다 */} {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? (

diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index cefaa571b..73088e293 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -50,6 +50,7 @@ import SmartGuidesOverlay from '../overlays/SmartGuidesOverlay'; import MarqueeSelectionOverlay from '../overlays/MarqueeSelectionOverlay'; import ResizeHandles from '../handles/ResizeHandles'; import GradientAxisOverlay from '../handles/GradientAxisHandle'; +import SpritePoseGizmo from '../handles/SpritePoseGizmo'; import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; import GroupResizeHandles from '../handles/GroupResizeHandles'; import { @@ -2004,6 +2005,8 @@ const Grid = ({ panX={panX} panY={panY} /> + {/* 온캔버스 스프라이트 자세 기즈모 — 자세 팝업이 열려 있을 때만 표시 */} + {/* 우클릭 리스트 팝업 */}

({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +// 축(50,50)·핀(50,200) - 아래로 뻗은 팔, 기본 길이 150px +const makeSession = ( + overrides: Partial = {}, +): SpritePoseGizmoSession => ({ + positionId: 'sprite-1', + poseId: 'pose-1', + origin: { dx: 0, dy: 0 }, + imageRect: { x: 0, y: 0, width: 100, height: 200 }, + pivot: { x: 0.5, y: 0.25 }, + contactPoint: { x: 0.5, y: 1 }, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + stretch: false, + preview: vi.fn(), + commit: vi.fn(), + cancel: vi.fn(), + commitContactPoint: vi.fn(), + ...overrides, +}); + +describe('SpritePoseGizmo', () => { + let container: HTMLDivElement; + let root: Root; + + const knob = () => + container.querySelector('[data-sprite-contact-knob="true"]'); + + const pointer = ( + type: string, + target: EventTarget, + init: { clientX?: number; clientY?: number; pointerId?: number } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + pointerId: init.pointerId ?? 1, + }), + ); + }); + + beforeEach(() => { + // rAF 스케줄러를 타이머로 강등 - flush 경로와 분리해 결정적으로 만든다 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + act(() => useSpritePoseGizmoStore.getState().setSession(null)); + releaseDragSession(); + vi.unstubAllGlobals(); + }); + + const render = (session: SpritePoseGizmoSession) => { + act(() => useSpritePoseGizmoStore.getState().setSession(session)); + act(() => { + root.render(); + }); + }; + + it('세션이 없으면 그리지 않고, 있으면 핀 위치에 노브를 그린다', () => { + act(() => { + root.render(); + }); + expect(knob()).toBeNull(); + + render(makeSession()); + const el = knob()!; + // 핀 월드 (50,200), 히트 박스 22px 중앙 정렬 + expect(el.style.left).toBe('39px'); + expect(el.style.top).toBe('189px'); + }); + + it('노브 드래그는 up에서 마지막 move를 flush해 커밋한다 - 축 고정 회전 역산', () => { + const session = makeSession(); + render(session); + + pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); + // rAF 타이머를 돌리지 않아도 up의 flush가 이 좌표를 반영해야 한다 + pointer('pointermove', window, { clientX: 200, clientY: 50 }); + pointer('pointerup', window, { clientX: 200, clientY: 50 }); + + expect(session.commit).toHaveBeenCalledTimes(1); + const committed = (session.commit as ReturnType).mock + .calls[0][0]; + // 아래(90°) → 오른쪽(0°) 목표, 축 고정이라 x·y 불변 + expect(committed.rotation).toBeCloseTo(-90, 4); + expect(committed.scale).toBe(1); + expect(committed.x).toBe(0); + expect(committed.y).toBe(0); + expect(session.cancel).not.toHaveBeenCalled(); + }); + + it('다른 pointerId의 move·up은 활성 드래그에 개입하지 못한다', () => { + const session = makeSession(); + render(session); + + pointer('pointerdown', knob()!, { + clientX: 50, + clientY: 200, + pointerId: 1, + }); + pointer('pointermove', window, { + clientX: 999, + clientY: 999, + pointerId: 2, + }); + pointer('pointerup', window, { clientX: 999, clientY: 999, pointerId: 2 }); + expect(session.commit).not.toHaveBeenCalled(); + + pointer('pointermove', window, { clientX: 200, clientY: 50, pointerId: 1 }); + pointer('pointerup', window, { clientX: 200, clientY: 50, pointerId: 1 }); + expect(session.commit).toHaveBeenCalledTimes(1); + expect( + (session.commit as ReturnType).mock.calls[0][0].rotation, + ).toBeCloseTo(-90, 4); + }); + + it('드래그 중 세션 종료는 제스처를 취소하고 전역 드래그 락을 돌려놓는다', () => { + const session = makeSession(); + render(session); + + pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); + // 드래그가 락을 점유 중이다 + expect(tryAcquireDragSession()).toBe(false); + + act(() => useSpritePoseGizmoStore.getState().setSession(null)); + + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(session.commit).not.toHaveBeenCalled(); + // 락 반환 - 이후 요소 드래그가 다시 가능해야 한다 + expect(tryAcquireDragSession()).toBe(true); + releaseDragSession(); + + // 유령 up이 와도 커밋되지 않는다 + pointer('pointerup', window, { clientX: 200, clientY: 50 }); + expect(session.commit).not.toHaveBeenCalled(); + }); + + it('Alt 드래그는 transform 대신 핀 위치를 커밋한다', () => { + const session = makeSession(); + render(session); + + act(() => { + knob()!.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + clientX: 50, + clientY: 200, + pointerId: 1, + altKey: true, + }), + ); + }); + pointer('pointermove', window, { clientX: 50, clientY: 100 }); + pointer('pointerup', window, { clientX: 50, clientY: 100 }); + + expect(session.commit).not.toHaveBeenCalled(); + expect(session.commitContactPoint).toHaveBeenCalledTimes(1); + const pin = (session.commitContactPoint as ReturnType).mock + .calls[0][0]; + // (50,100) → 이미지 정규화 (0.5, 0.5) + expect(pin.x).toBeCloseTo(0.5, 4); + expect(pin.y).toBeCloseTo(0.5, 4); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx new file mode 100644 index 000000000..2cfec2dcc --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -0,0 +1,393 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from '@contexts/useTranslation'; +import { useSpritePoseGizmoStore } from '@stores/grid/useSpritePoseGizmoStore'; +import { + CONTACT_EPSILON, + contactWorldPosition, + solveTransformTowardTarget, + type ContactGeometry, +} from '@utils/sprite/contactSolver'; +import { + SPRITE_CONSTRAINTS, + type SpriteAnchor, + type SpriteTransform, +} from '@src/types/key/sprites'; +import { createRafLatestScheduler } from '@utils/animation/rafLatestScheduler'; +import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; +import { + releaseDragSession, + tryAcquireDragSession, +} from '@hooks/Grid/dragSession'; + +/** + * 온캔버스 자세 기즈모 - 자세 팝업이 열려 있는 동안 손끝(핀) 노브를 그린다. + * 노브 드래그 = 축(기준점) 고정 역산: x·y를 두고 rotation(·뻗기 시 scale)을 계산. + * Alt 드래그 = 핀 배치: transform은 두고 contactPoint만 옮긴다. + * 배치는 GradientAxisOverlay와 동일한 비스케일 오버레이 층 - zoom/pan을 직접 계산 + */ + +interface SpritePoseGizmoProps { + zoom: number; + panX: number; + panY: number; +} + +type DragMode = 'aim' | 'pin'; + +interface DragState { + mode: DragMode; + pointerId: number; + /** 드래그 시작 시점 transform - 뻗기 OFF의 scale 기준 */ + baseTransform: SpriteTransform; + /** 시작 시점 세션 - 스토어 세션이 먼저 닫혀도 cancel 배선을 유지한다 */ + session: import('@stores/grid/useSpritePoseGizmoStore').SpritePoseGizmoSession; +} + +const KNOB_HIT_SIZE = 22; +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { + const { t } = useTranslation(); + const session = useSpritePoseGizmoStore((state) => state.session); + + const rootRef = useRef(null); + const dragRef = useRef(null); + const grabbedRef = useRef<{ el: HTMLElement; pointerId: number } | null>( + null, + ); + const detachRef = useRef<(() => void) | null>(null); + const moveSchedulerRef = useRef + > | null>(null); + const ownerGenerationRef = useRef(0); + const lastSolvedRef = useRef(null); + const lastPinRef = useRef(null); + + // 드래그 중 로컬 표시값 - preview 채널과 별개로 노브를 즉시 따라가게 한다 + const [dragTransform, setDragTransform] = useState( + null, + ); + const [dragPin, setDragPin] = useState(null); + + // 드래그 도중 휠 팬·줌이 좌표를 움직여도 매 이벤트에서 최신 지오메트리로 + // 재계산한다 (GradientAxisHandle과 같은 이유의 렌더 중 ref 대입) + const geoRef = useRef<{ + geometry: ContactGeometry; + origin: { dx: number; dy: number }; + zoom: number; + panX: number; + panY: number; + } | null>(null); + const sessionRef = useRef(session); + // eslint-disable-next-line react-hooks/refs + sessionRef.current = session; + + // 활성 드래그 취소 배선 - 세션 종료(Escape·대상 소실)·대상 교체·언마운트에서 + // window 리스너·포인터 캡처·전역 드래그 락·열린 preview 제스처를 함께 정리한다 + const cancelDragRef = useRef<(() => void) | null>(null); + const ownerKey = session ? `${session.positionId}\n${session.poseId}` : null; + useEffect(() => { + if (dragRef.current) cancelDragRef.current?.(); + }, [ownerKey]); + useEffect(() => () => cancelDragRef.current?.(), []); + + if (!session) { + if (dragTransform !== null || dragPin !== null) { + // 세션이 닫히면 로컬 드래그 상태도 함께 비운다 (렌더 중 보정 패턴) + setDragTransform(null); + setDragPin(null); + } + return null; + } + + const geometry: ContactGeometry = { + imageRect: session.imageRect, + pivot: session.pivot, + contactPoint: dragPin ?? session.contactPoint, + }; + // eslint-disable-next-line react-hooks/refs + geoRef.current = { + geometry, + origin: session.origin, + zoom, + panX, + panY, + }; + + const displayTransform = dragTransform ?? session.transform; + const axisPx = { + x: session.imageRect.x + session.pivot.x * session.imageRect.width, + y: session.imageRect.y + session.pivot.y * session.imageRect.height, + }; + const contactWorld = contactWorldPosition(geometry, displayTransform); + const knobScreen = { + x: (session.origin.dx + contactWorld.x) * zoom + panX, + y: (session.origin.dy + contactWorld.y) * zoom + panY, + }; + const axisScreen = { + x: (session.origin.dx + displayTransform.x + axisPx.x) * zoom + panX, + y: (session.origin.dy + displayTransform.y + axisPx.y) * zoom + panY, + }; + + // 핀=축 퇴화 - 방향이 정의되지 않아 조작 불가, 핀 재배치(Alt)만 허용 + const baseVector = { + x: (geometry.contactPoint.x - session.pivot.x) * session.imageRect.width, + y: (geometry.contactPoint.y - session.pivot.y) * session.imageRect.height, + }; + const degenerate = + Math.hypot(baseVector.x, baseVector.y) <= CONTACT_EPSILON * 1000; + + const clientToElementLocal = (clientX: number, clientY: number) => { + const geo = geoRef.current; + const hostRect = rootRef.current?.getBoundingClientRect(); + if (!geo || !hostRect) return null; + return { + x: (clientX - hostRect.left - geo.panX) / geo.zoom - geo.origin.dx, + y: (clientY - hostRect.top - geo.panY) / geo.zoom - geo.origin.dy, + }; + }; + + // Alt 드래그의 핀 역산 - 화면 목표를 이미지 정규화 좌표로 되돌린다 + const targetToPin = (target: { x: number; y: number }): SpriteAnchor => { + const geo = geoRef.current; + const live = sessionRef.current; + if (!geo || !live) return { x: 0.5, y: 1 }; + const { imageRect, pivot } = geo.geometry; + const axis = { + x: imageRect.x + pivot.x * imageRect.width, + y: imageRect.y + pivot.y * imageRect.height, + }; + const transform = lastSolvedRef.current ?? live.transform; + const rad = (-transform.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const scale = Math.max(transform.scale, SPRITE_CONSTRAINTS.scale.min); + const dx = (target.x - transform.x - axis.x) / scale; + const dy = (target.y - transform.y - axis.y) / scale; + const pre = { + x: axis.x + dx * cos - dy * sin, + y: axis.y + dx * sin + dy * cos, + }; + return { + x: clamp01((pre.x - imageRect.x) / imageRect.width), + y: clamp01((pre.y - imageRect.y) / imageRect.height), + }; + }; + + const applyWindowMove = (event: PointerEvent) => { + const drag = dragRef.current; + const live = sessionRef.current; + if (!drag || !live) return; + const target = clientToElementLocal(event.clientX, event.clientY); + if (!target) return; + + if (drag.mode === 'pin') { + const pin = targetToPin(target); + lastPinRef.current = pin; + setDragPin(pin); + return; + } + + const solved = solveTransformTowardTarget( + geoRef.current!.geometry, + drag.baseTransform, + target, + live.stretch, + ); + if (solved.status !== 'ok') return; + lastSolvedRef.current = solved.transform; + setDragTransform(solved.transform); + live.preview(solved.transform); + }; + + const suppressNextClick = () => { + const swallow = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + window.removeEventListener('click', swallow, true); + }; + window.addEventListener('click', swallow, true); + setTimeout(() => window.removeEventListener('click', swallow, true), 0); + }; + + const releaseGrabbed = () => { + const grabbed = grabbedRef.current; + if (!grabbed) return; + endDragCursor(grabbed.el.ownerDocument); + try { + grabbed.el.releasePointerCapture(grabbed.pointerId); + } catch { + // 이미 해제됐으면 무시 + } + grabbedRef.current = null; + }; + + const finishDrag = () => { + if (!dragRef.current) return; + cancelDragRef.current = null; + releaseDragSession(); + releaseGrabbed(); + moveSchedulerRef.current?.cancel(); + moveSchedulerRef.current = null; + detachRef.current?.(); + detachRef.current = null; + dragRef.current = null; + setDragTransform(null); + setDragPin(null); + lastSolvedRef.current = null; + lastPinRef.current = null; + suppressNextClick(); + }; + + const handleWindowUp = (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + // 다른 포인터의 up이 첫 드래그를 커밋하지 못하게 한다 (멀티터치) + if (event.pointerId !== drag.pointerId) return; + // 같은 프레임의 마지막 move가 rAF에 남아 있으면 커밋 전에 반영한다 + moveSchedulerRef.current?.flush(); + if (ownerGenerationRef.current === generationNow()) { + if (drag.mode === 'aim' && lastSolvedRef.current) { + drag.session.commit(lastSolvedRef.current); + } else if (drag.mode === 'pin' && lastPinRef.current) { + drag.session.commitContactPoint(lastPinRef.current); + } + } + finishDrag(); + }; + + const cancelActiveDrag = () => { + const drag = dragRef.current; + if (!drag) return; + // 스토어 세션이 먼저 닫혔어도 시작 시점 세션으로 preview 제스처를 닫는다 + if (drag.mode === 'aim') drag.session.cancel(); + finishDrag(); + }; + + const generationNow = () => useSpritePoseGizmoStore.getState().generation; + + const handleWindowMove = (event: PointerEvent) => { + if (event.pointerId !== dragRef.current?.pointerId) return; + moveSchedulerRef.current?.push(event); + }; + const handleWindowCancel = (event: PointerEvent) => { + if (event.pointerId !== dragRef.current?.pointerId) return; + cancelActiveDrag(); + }; + const handleWindowBlur = () => cancelActiveDrag(); + + const beginDrag = (event: React.PointerEvent) => { + if (event.button !== 0) return; + if (dragRef.current) return; + const mode: DragMode = event.altKey ? 'pin' : 'aim'; + if (mode === 'aim' && degenerate) return; + event.preventDefault(); + event.stopPropagation(); + if (!tryAcquireDragSession()) return; + + const el = event.currentTarget as HTMLElement; + try { + el.setPointerCapture(event.pointerId); + } catch { + // 미지원 환경은 커서 고정 없이 동작만 유지 + } + beginDragCursor('grabbing', el.ownerDocument); + grabbedRef.current = { el, pointerId: event.pointerId }; + ownerGenerationRef.current = generationNow(); + dragRef.current = { + mode, + pointerId: event.pointerId, + baseTransform: session.transform, + session, + }; + cancelDragRef.current = cancelActiveDrag; + moveSchedulerRef.current = createRafLatestScheduler(applyWindowMove); + window.addEventListener('pointermove', handleWindowMove); + window.addEventListener('pointerup', handleWindowUp); + window.addEventListener('pointercancel', handleWindowCancel); + window.addEventListener('blur', handleWindowBlur); + detachRef.current = () => { + window.removeEventListener('pointermove', handleWindowMove); + window.removeEventListener('pointerup', handleWindowUp); + window.removeEventListener('pointercancel', handleWindowCancel); + window.removeEventListener('blur', handleWindowBlur); + }; + }; + + const knobTitle = degenerate + ? t('propertiesPanel.spriteContactDegenerate') || + '핀이 기준점과 겹쳐 있습니다. Alt 드래그로 핀을 옮기세요' + : t('propertiesPanel.spriteContactKnobHint') || + '드래그: 손끝을 키에 조준 · Alt 드래그: 핀 위치 이동'; + + return ( +
+ {/* 축→핀 안내선 */} + + {/* 손끝 노브 - 유일한 히트 대상 */} +
+
+
+
+ ); +}; + +export default SpritePoseGizmo; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index d709c05f9..9d686b325 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -45,6 +45,8 @@ const SPRITE_ID = '00000000-0000-4000-8000-000000000801'; const spritePosition = ( overrides: Partial = {}, ): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: SPRITE_ID, dx: 10, dy: 20, @@ -72,7 +74,7 @@ let root: Root; const renderSprite = ( position: CanonicalReactiveSpritePosition, - options: { isSelected?: boolean } = {}, + options: { isSelected?: boolean; zoom?: number } = {}, ) => { act(() => { root.render( @@ -183,6 +185,7 @@ describe('SpriteItem 자세 편집 프리뷰', () => { poseId: string, overrides: Partial = {}, ): SpritePose => ({ + contactPoint: { x: 0.5, y: 1 }, poseId, triggers: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], transform: { x: 10, y: 5, rotation: 45, scale: 2 }, diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 139b6740b..39362f38a 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -1,7 +1,10 @@ 'use no memo'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useSignals } from '@preact/signals-react/runtime'; -import { getKeySignal } from '@stores/signals/keySignals'; +import { + getKeySignal, + subscribeKeyPressEdge, +} from '@stores/signals/keySignals'; import { isErrorForCurrentSrc, useFailedImageSrcs, @@ -11,6 +14,7 @@ import { warmupImageSource } from '@utils/core/imageWarmup'; import { resolveSpriteTarget } from '@utils/sprite/poseResolver'; import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; import { resolveSpriteRenderEasing } from '@utils/sprite/spriteReach'; +import { spriteTransformToCss } from '@src/types/key/sprites'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; interface OverlaySpriteItemProps { @@ -18,15 +22,27 @@ interface OverlaySpriteItemProps { keyCanonicalMap: ReadonlyMap; } +// 단발(onPress) 재생 상태 - 재트리거 소유권 검증용 세대 포함. +// 늦게 도착한 이전 재생의 복원 콜백이 새 재생의 이미지를 덮지 못하게 한다 +interface OneShotPlayback { + generation: number; + animation: Animation | null; + timer: number | null; +} + // 키 입력에 따라 자세가 바뀌는 반응형 스프라이트 잎 컴포넌트. -// 담당 키 시그널을 여기서만 읽어 눌림 변화가 이 잎의 리렌더로만 남는다. -// 변환은 CSS transition이 컴포지터에서 보간하고 자세 이미지는 스냅 교체 (계약 §9) +// whileHeld: 담당 키 시그널을 여기서만 읽어 눌림 변화가 이 잎의 리렌더로만 남고, +// 변환은 CSS transition이 컴포지터에서 보간한다 (계약 §9). +// onPress: 렌더는 항상 idle로 고정하고, 실입력 DOWN edge에서 WAAPI가 +// 자세→idle 한 방향 재생을 수행한다 (React state 미경유, 탭당 리렌더 0) const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ position, keyCanonicalMap, }: OverlaySpriteItemProps) { useSignals(); + const isOneShot = position.activation === 'onPress'; + // 자세들이 참조하는 키 요소 id 전체 (중복 제거) const triggerIds = useMemo(() => { const seen = new Set(); @@ -41,15 +57,20 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ return ids; }, [position.poses]); - // 렌더 중 시그널 읽기로 구독 형성 - 매핑에 없는 id(죽은 레인)는 구독하지 않는다 + // whileHeld만 렌더 중 시그널을 읽어 구독을 형성한다 - onPress는 edge 채널 전용이라 + // 눌림 레벨 변화에 리렌더될 이유가 없다 const pressedIds = new Set(); - for (const id of triggerIds) { - const canonical = keyCanonicalMap.get(id); - if (!canonical) continue; - if (getKeySignal(canonical).value) pressedIds.add(id); + if (!isOneShot) { + for (const id of triggerIds) { + const canonical = keyCanonicalMap.get(id); + if (!canonical) continue; + if (getKeySignal(canonical).value) pressedIds.add(id); + } } - const target = resolveSpriteTarget(position, pressedIds); + const target = isOneShot + ? { transform: position.idleTransform, imageSrc: position.baseImage } + : resolveSpriteTarget(position, pressedIds); const { failedImageSrcs, markFailed } = useFailedImageSrcs( position.baseImage, @@ -61,13 +82,14 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ // easing 해석은 정규식·베지어 극값 계산이라 렌더마다 돌리지 않는다. // 발산 easing은 도달 계산과 같은 기준으로 강등 (창 클리핑 방지) - const transitionCss = useMemo( - () => - `transform ${position.transitionMs}ms ${resolveSpriteRenderEasing( - position.transitionEasing, - )}`, - [position.transitionMs, position.transitionEasing], + const renderEasing = useMemo( + () => resolveSpriteRenderEasing(position.transitionEasing), + [position.transitionEasing], ); + // onPress는 전환 채널 미사용 - WAAPI가 유일한 보간자다 + const transitionCss = isOneShot + ? undefined + : `transform ${position.transitionMs}ms ${renderEasing}`; // 실패한 src는 baseImage로 폴백, 그것도 실패면 렌더 제외 let imageSrc = targetImageSrc; @@ -90,6 +112,162 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ for (const src of warmupSrcs) warmupImageSource(src); }, [warmupSrcs]); + // 기본 이미지가 없어도 자세 override만으로 유효한 onPress 스프라이트가 성립한다. + // 안정 img 노드를 유지하고 idle에는 숨겨야 edge 핸들러가 재생할 대상이 있다 + const hasOverrideImage = position.poses.some( + (pose) => pose.imageOverride != null, + ); + const mountOneShotImage = + isOneShot && (baseImageSrc !== null || hasOverrideImage); + + const imgRef = useRef(null); + const playbackRef = useRef({ + generation: 0, + animation: null, + timer: null, + }); + // 진행 중 재생 정리 - 세대 무효화로 늦은 콜백까지 차단 (멱등) + const stopPlayback = () => { + const playback = playbackRef.current; + playback.generation += 1; + if (playback.animation) { + playback.animation.onfinish = null; + playback.animation.oncancel = null; + playback.animation.cancel(); + playback.animation = null; + } + if (playback.timer !== null) { + clearTimeout(playback.timer); + playback.timer = null; + } + }; + + // edge 핸들러는 구독 effect보다 자주 갱신되는 값을 ref로 읽는다 (재구독 방지) + const latestRef = useRef({ position, failedImageSrcs }); + useEffect(() => { + latestRef.current = { position, failedImageSrcs }; + }); + + useEffect(() => { + if (!isOneShot) return undefined; + // cleanup 복원 대상 노드 - 같은 분기 재구독 동안은 key가 같아 안정적이고, + // 모드 전환 시에는 어차피 key 재마운트가 잔상을 걷는다 + const mountedImg = imgRef.current; + + const canonicals = new Set(); + for (const id of triggerIds) { + const canonical = keyCanonicalMap.get(id); + if (canonical) canonicals.add(canonical); + } + if (canonicals.size === 0) return undefined; + + const handleEdge = () => { + const el = imgRef.current; + if (!el) return; + const { position: pos, failedImageSrcs: failed } = latestRef.current; + + // 눌린 집합은 시그널 peek로만 만든다 - 여기서 구독이 생기면 안 된다 + const pressed = new Set(); + for (const id of triggerIds) { + const canonical = keyCanonicalMap.get(id); + if (canonical && getKeySignal(canonical).value) pressed.add(id); + } + const resolved = resolveSpriteTarget(pos, pressed); + + let src = resolveImageSource(resolved.imageSrc); + if (src && failed.has(src)) src = resolveImageSource(pos.baseImage); + if (src && failed.has(src)) src = null; + const baseSrc = resolveImageSource(pos.baseImage); + + const playback = playbackRef.current; + // 재트리거 소유권 이전 - 이전 재생의 콜백을 떼고 취소한다 + stopPlayback(); + const generation = playback.generation; + + if (src) { + el.src = src; + el.style.visibility = ''; + } else { + // 재생할 이미지가 없으면 이전 세대의 잔상을 즉시 걷어낸다 + el.removeAttribute('src'); + el.style.visibility = baseSrc ? '' : 'hidden'; + if (baseSrc) el.src = baseSrc; + } + const restore = () => { + if (playbackRef.current.generation !== generation) return; + // 재생 중 문서가 바뀌었을 수 있어 캡처값 대신 최신 baseImage로 복원 + const currentBase = resolveImageSource( + latestRef.current.position.baseImage, + ); + if (currentBase) { + el.src = currentBase; + el.style.visibility = ''; + } else { + el.removeAttribute('src'); + el.style.visibility = 'hidden'; + } + }; + + const fromCss = spriteTransformToCss(resolved.transform); + const toCss = spriteTransformToCss(pos.idleTransform); + if (typeof el.animate === 'function') { + // 자세에서 시작해 idle로 돌아오는 한 방향 재생 - 스냅 타격 + 이즈 복귀. + // fill: none이라 종료 후에는 CSS 변수 채널(idle)이 그대로 복귀값이다 + const animation = el.animate( + [{ transform: fromCss }, { transform: toCss }], + { + duration: pos.pressDurationMs, + easing: resolveSpriteRenderEasing(pos.transitionEasing), + fill: 'none', + }, + ); + const settle = () => { + if (playbackRef.current.generation !== generation) return; + playbackRef.current.animation = null; + restore(); + }; + animation.onfinish = settle; + // 외부 취소(사용자 스크립트 등)도 idle 이미지 복원 계약을 지킨다 + animation.oncancel = settle; + playback.animation = animation; + } else { + // WAAPI 부재 폴백 - 자세로 스냅했다가 시간 후 복귀 (보간 없음) + el.style.transform = fromCss; + playback.timer = window.setTimeout(() => { + if (playbackRef.current.generation !== generation) return; + playbackRef.current.timer = null; + el.style.transform = ''; + restore(); + }, pos.pressDurationMs); + } + }; + + const unsubscribes = [...canonicals].map((canonical) => + subscribeKeyPressEdge(canonical, handleEdge), + ); + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + // whileHeld 전환·트리거 교체 시 진행 중 재생을 함께 끊는다 + stopPlayback(); + // 직접 쓴 src·visibility 잔상 제거 - React는 prop이 같으면 DOM을 다시 + // 쓰지 않으므로 여기서 현재 문서 기준으로 복원한다 (전환 시에는 key가 + // 노드를 갈아 끼우고, 같은 분기 안 재구독에는 이 복원이 잡는다) + const el = mountedImg; + if (el) { + const currentBase = resolveImageSource( + latestRef.current.position.baseImage, + ); + if (currentBase) { + el.src = currentBase; + el.style.visibility = ''; + } else { + el.removeAttribute('src'); + el.style.visibility = 'hidden'; + } + } + }; + }, [isOneShot, triggerIds, keyCanonicalMap]); + if (position.hidden) return null; // 히트 기준은 활동 영역 박스(루트) - 에디터 선택 박스와 같은 의미이고 포즈 전환 @@ -109,28 +287,52 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ }} data-sprite-element="true" data-overlay-hit="true" - data-state={pressedIds.size > 0 ? 'active' : 'idle'} + data-state={!isOneShot && pressedIds.size > 0 ? 'active' : 'idle'} > - {imageSrc && ( - { - if (!isErrorForCurrentSrc(event.currentTarget, imageSrc)) return; - markFailed(imageSrc); - }} - /> - )} + {isOneShot + ? mountOneShotImage && ( + { + const src = event.currentTarget.getAttribute('src'); + if (src) markFailed(src); + }} + /> + ) + : imageSrc && ( + { + if (!isErrorForCurrentSrc(event.currentTarget, imageSrc)) + return; + markFailed(imageSrc); + }} + /> + )}
); }); diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index 44bf0dd10..f7eae8b64 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -9,7 +9,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import type { SpritePose } from '@src/types/key/sprites'; -import { resetAllKeySignals, setKeyActive } from '@stores/signals/keySignals'; +import { + applyEventKeyState, + resetAllKeySignals, + setKeyActive, +} from '@stores/signals/keySignals'; import OverlaySpriteItem from './OverlaySpriteItem'; @@ -25,6 +29,7 @@ const makePose = ( triggers: string[], overrides: Partial = {}, ): SpritePose => ({ + contactPoint: { x: 0.5, y: 1 }, poseId, triggers, transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, @@ -35,6 +40,8 @@ const makePose = ( const makeSprite = ( overrides: Partial = {}, ): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: 'sprite-1', dx: 12, dy: 24, @@ -236,3 +243,213 @@ describe('OverlaySpriteItem', () => { expect(spriteEl()).toBeNull(); }); }); + +// onPress 단발 재생 - edge 채널·WAAPI 소유권·이미지 수명주기 +describe('OverlaySpriteItem onPress', () => { + interface MockAnimation { + keyframes: Array>; + options: KeyframeAnimationOptions; + cancel: () => void; + cancelled: boolean; + onfinish: (() => void) | null; + oncancel: (() => void) | null; + } + + let container: HTMLDivElement; + let root: Root; + let animations: MockAnimation[]; + const proto = HTMLImageElement.prototype as unknown as { animate?: unknown }; + const originalAnimate = proto.animate; + + const oneShotSprite = ( + overrides: Partial = {}, + ) => + makeSprite({ + activation: 'onPress', + pressDurationMs: 300, + poses: [makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE })], + ...overrides, + }); + + const render = ( + position: CanonicalReactiveSpritePosition, + map: ReadonlyMap = keyCanonicalMap, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + const imgEl = () => container.querySelector('img'); + + beforeEach(() => { + animations = []; + proto.animate = function animate( + keyframes: Array>, + options: KeyframeAnimationOptions, + ) { + const animation: MockAnimation = { + keyframes, + options, + cancelled: false, + cancel: () => { + animation.cancelled = true; + animation.oncancel?.(); + }, + onfinish: null, + oncancel: null, + }; + animations.push(animation); + return animation; + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + resetAllKeySignals(); + if (originalAnimate === undefined) delete proto.animate; + else proto.animate = originalAnimate; + }); + + it('실입력 DOWN edge가 자세→idle 재생을 시작하고 종료 시 기본 이미지로 복원한다', () => { + render(oneShotSprite()); + const img = imgEl()!; + expect(img.src).toContain(BASE_IMAGE); + + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + expect(animations[0].keyframes[0].transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + expect(animations[0].keyframes[1].transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + expect(animations[0].options.duration).toBe(300); + expect(animations[0].options.fill).toBe('none'); + expect(img.src).toContain(OVERRIDE_IMAGE); + + act(() => animations[0].onfinish?.()); + expect(img.src).toContain(BASE_IMAGE); + }); + + it('하이드레이션 레벨 세팅은 재생을 트리거하지 않는다 - 유령 단발 방지', () => { + render(oneShotSprite()); + act(() => setKeyActive('KeyA', true)); + act(() => setKeyActive('KeyA', false)); + expect(animations).toHaveLength(0); + }); + + it('이미 눌린 키의 반복 DOWN은 edge가 아니다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + }); + + it('재트리거는 이전 재생을 취소하고, 늦은 복원 콜백이 새 이미지를 덮지 않는다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + const first = animations[0]; + // 소유권 검증 대상 - 재트리거 전에 콜백을 캡처해 지연 호출을 재현한다 + const lateRestore = first.onfinish; + + act(() => applyEventKeyState('KeyA', false)); + act(() => applyEventKeyState('KeyA', true)); + expect(first.cancelled).toBe(true); + expect(first.onfinish).toBeNull(); + expect(animations).toHaveLength(2); + + lateRestore?.(); + expect(imgEl()!.src).toContain(OVERRIDE_IMAGE); + + act(() => animations[1].onfinish?.()); + expect(imgEl()!.src).toContain(BASE_IMAGE); + }); + + it('기본 이미지 없이 자세 이미지만 있어도 재생 순간에만 표시된다', () => { + render(oneShotSprite({ baseImage: null })); + const img = imgEl()!; + expect(img.style.visibility).toBe('hidden'); + expect(img.hasAttribute('src')).toBe(false); + + act(() => applyEventKeyState('KeyA', true)); + expect(img.style.visibility).toBe(''); + expect(img.src).toContain(OVERRIDE_IMAGE); + + act(() => animations[0].onfinish?.()); + expect(img.style.visibility).toBe('hidden'); + expect(img.hasAttribute('src')).toBe(false); + }); + + it('리싱크가 레벨을 선점해도 실제 DOWN edge는 재생된다', () => { + render(oneShotSprite()); + // OBS 대조·하이드레이션이 이벤트보다 먼저 레벨을 true로 올린 상황 + act(() => setKeyActive('KeyA', true)); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + }); + + it('이미지 없는 자세로 재트리거하면 이전 자세 이미지 잔상을 걷어낸다', () => { + render( + oneShotSprite({ + baseImage: null, + poses: [ + makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE }), + makePose('p2', ['el-b']), + ], + }), + new Map([ + ['el-a', 'KeyA'], + ['el-b', 'KeyB'], + ]), + ); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + act(() => applyEventKeyState('KeyA', false)); + + act(() => applyEventKeyState('KeyB', true)); + expect(animations).toHaveLength(2); + expect(img.hasAttribute('src')).toBe(false); + expect(img.style.visibility).toBe('hidden'); + }); + + it('whileHeld로 전환하면 진행 중 재생이 취소된다', () => { + const sprite = oneShotSprite(); + render(sprite); + act(() => applyEventKeyState('KeyA', true)); + expect(animations[0].cancelled).toBe(false); + + act(() => applyEventKeyState('KeyA', false)); + render({ ...sprite, activation: 'whileHeld' }); + expect(animations[0].cancelled).toBe(true); + // 재생 중 직접 쓴 override src가 새 모드로 새지 않는다 (분기 key 재마운트) + expect(imgEl()!.src).toContain(BASE_IMAGE); + }); + + it('외부 취소도 기본 이미지 복원 계약을 지킨다', () => { + render(oneShotSprite()); + const img = imgEl()!; + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + + // 우리 재트리거·정리가 아닌 외부 cancel (콜백이 붙은 채로 취소됨) + act(() => animations[0].cancel()); + expect(img.src).toContain(BASE_IMAGE); + }); + + it('whileHeld 스프라이트는 edge 채널로 재생되지 않는다', () => { + render(makeSprite()); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(0); + }); +}); diff --git a/src/renderer/editor/runtime/elementOps.test.ts b/src/renderer/editor/runtime/elementOps.test.ts index a6b86ba54..087d1f0cc 100644 --- a/src/renderer/editor/runtime/elementOps.test.ts +++ b/src/renderer/editor/runtime/elementOps.test.ts @@ -138,6 +138,8 @@ const graphAt = ( }); const spriteAt = (id: string): ReactiveSpritePosition & { id: string } => ({ + activation: 'whileHeld', + pressDurationMs: 300, id, dx: 0, dy: 0, @@ -446,12 +448,14 @@ describe('elementOps', () => { ...spriteAt(crypto.randomUUID()), poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-src-1', triggers: [ID_A], transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, imageOverride: 'sprites/override.png', }, { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-src-2', triggers: [ID_A, ID_B], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, diff --git a/src/renderer/editor/runtime/mixedElementGroups.test.ts b/src/renderer/editor/runtime/mixedElementGroups.test.ts index 8fbd417b5..e636bb362 100644 --- a/src/renderer/editor/runtime/mixedElementGroups.test.ts +++ b/src/renderer/editor/runtime/mixedElementGroups.test.ts @@ -513,6 +513,8 @@ describe('setMixedLayerGroupHidden', () => { positions: { '4key': [ { + activation: 'whileHeld', + pressDurationMs: 300, id: SPRITE_ID, dx: 0, dy: 0, diff --git a/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts b/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts index 2d8adad88..f92c15fb9 100644 --- a/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts +++ b/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts @@ -76,6 +76,8 @@ let harness: Harness | null = null; // 백엔드 실물 wire 형태: layerName·groupId는 None이면 직렬화에서 생략된다 const spriteFixture = (): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: SPRITE_ID, dx: 0, dy: 0, @@ -193,6 +195,7 @@ describe('스프라이트 필드 패치 낙관 적용 통합', () => { triggers: [B_ID, A_ID], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, }; await editorCoordinator.start(); diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx index 6fd3e3423..9fd7841f2 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx +++ b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx @@ -84,6 +84,8 @@ const knob = (id: string) => ({ }); const sprite = (id: string): ReactiveSpritePosition & { id: string } => ({ + activation: 'whileHeld', + pressDurationMs: 300, id, dx: 0, dy: 0, diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.ts b/src/renderer/hooks/Grid/useGridCanvasActions.ts index 06dbc3c80..71ede193e 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.ts +++ b/src/renderer/hooks/Grid/useGridCanvasActions.ts @@ -30,7 +30,9 @@ import type { GraphItemPosition } from '@src/types/key/graphItems'; import type { KnobItemPosition } from '@src/types/key/knobs'; import { CENTER_SPRITE_ANCHOR, + DEFAULT_SPRITE_ACTIVATION, DEFAULT_SPRITE_IMAGE_FIT, + DEFAULT_SPRITE_PRESS_DURATION_MS, DEFAULT_SPRITE_SIZE, DEFAULT_SPRITE_TRANSITION_EASING, DEFAULT_SPRITE_TRANSITION_MS, @@ -520,6 +522,8 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { pivot: { ...CENTER_SPRITE_ANCHOR }, idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, poses: [], + activation: DEFAULT_SPRITE_ACTIVATION, + pressDurationMs: DEFAULT_SPRITE_PRESS_DURATION_MS, transitionMs: DEFAULT_SPRITE_TRANSITION_MS, transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, }, diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index 2e59e4e9e..961abe4b2 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -53,10 +53,18 @@ vi.mock('@utils/plugin/bridgeMessages', () => ({ sendBridgeMessageBestEffort: mocks.sendBridge, })); -vi.mock('@src/renderer/editor/runtime/elementOps', () => ({ - commitElementBoundsById: mocks.commitGroupBounds, - commitSingleElementBoundsById: mocks.commitSingleBounds, -})); +vi.mock('@src/renderer/editor/runtime/elementOps', async (importOriginal) => { + // eager 적용·op 구성은 실물을 쓰고 wire 커밋만 mock - sprite projection + // 계약이 테스트에서 실제 경로로 돈다 + const actual = await importOriginal< + typeof import('@src/renderer/editor/runtime/elementOps') + >(); + return { + ...actual, + commitElementBoundsById: mocks.commitGroupBounds, + commitSingleElementBoundsById: mocks.commitSingleBounds, + }; +}); vi.mock('@stores/plugin/usePluginDisplayElementStore', () => ({ usePluginDisplayElementStore: { @@ -174,6 +182,8 @@ const STABLE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const STABLE_SPRITE = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id, dx: 0, dy: 0, @@ -192,6 +202,7 @@ const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [STABLE_A], transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index 2a21c9e75..a3f2a4024 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -130,6 +130,8 @@ const STABLE_GRAPH_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const STABLE_KNOB_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const STABLE_SPRITE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id, dx: 15, dy: 25, @@ -148,6 +150,7 @@ const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: [STABLE_KEY_ID], transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, @@ -549,6 +552,7 @@ describe('useGridSelection compound history gesture', () => { ...spriteAt(STABLE_SPRITE_ID), poses: [ { + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', // 배치 안 키와 배치 밖 키 참조 혼합 triggers: [STABLE_KEY_ID, externalKeyId], diff --git a/src/renderer/hooks/shared/useLayoutComputation.test.ts b/src/renderer/hooks/shared/useLayoutComputation.test.ts index d1275ef7b..6729caee0 100644 --- a/src/renderer/hooks/shared/useLayoutComputation.test.ts +++ b/src/renderer/hooks/shared/useLayoutComputation.test.ts @@ -20,6 +20,8 @@ const makeTransform = ( const makeSprite = ( overrides: Partial = {}, ): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: 'sprite-1', dx: 100, dy: 50, diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index ee18e0601..4cb7282a7 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -571,6 +571,15 @@ "spriteTransition": "Transition", "spriteEasing": "Motion", "spriteEasingCustom": "Custom", + "spriteActivation": "Reaction", + "spriteActivationHold": "While held", + "spriteActivationPress": "One-shot", + "spritePressDuration": "Play duration", + "spriteContactPoint": "Fingertip", + "spritePinLock": "Pin lock", + "spriteStretch": "Stretch", + "spriteContactKnobHint": "Drag: aim the fingertip at a key · Alt-drag: move the pin", + "spriteContactDegenerate": "The pin overlaps the pivot. Alt-drag to move the pin", "graphSpeed": "Graph Speed", "graphColor": "Graph Color", "graphAnimation": "Graph Motion", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 0317e580c..22dedce2d 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -571,6 +571,15 @@ "spriteTransition": "전환 시간", "spriteEasing": "모션", "spriteEasingCustom": "사용자 정의", + "spriteActivation": "반응 방식", + "spriteActivationHold": "누름 유지", + "spriteActivationPress": "단발 재생", + "spritePressDuration": "재생 시간", + "spriteContactPoint": "손끝", + "spritePinLock": "핀 고정", + "spriteStretch": "뻗기", + "spriteContactKnobHint": "드래그: 손끝을 키에 조준 · Alt 드래그: 핀 이동", + "spriteContactDegenerate": "핀이 기준점과 겹쳐 있습니다. Alt 드래그로 핀을 옮기세요", "graphSpeed": "그래프 속도", "graphColor": "그래프 색상", "graphAnimation": "그래프 모션", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 949be1e9e..91cf3f6cf 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -576,6 +576,15 @@ "spriteTransition": "Переход", "spriteEasing": "Моушн", "spriteEasingCustom": "Настраиваемый", + "spriteActivation": "Реакция", + "spriteActivationHold": "Удержание", + "spriteActivationPress": "Один раз", + "spritePressDuration": "Длительность", + "spriteContactPoint": "Кончик", + "spritePinLock": "Закрепить кончик", + "spriteStretch": "Растягивание", + "spriteContactKnobHint": "Перетаскивание: навести кончик на клавишу · Alt: переместить точку", + "spriteContactDegenerate": "Точка совпадает с осью вращения. Переместите её перетаскиванием с Alt", "customImage": "Своя картинка", "configure": "Настроить", "className": "Класс", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 6212ccea2..a2d3759a7 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -576,6 +576,15 @@ "spriteTransition": "過渡時間", "spriteEasing": "動態", "spriteEasingCustom": "自訂", + "spriteActivation": "反應方式", + "spriteActivationHold": "按住維持", + "spriteActivationPress": "單次播放", + "spritePressDuration": "播放時間", + "spriteContactPoint": "指尖", + "spritePinLock": "固定指尖", + "spriteStretch": "伸展", + "spriteContactKnobHint": "拖曳:將指尖對準按鍵 · Alt 拖曳:移動指尖點", + "spriteContactDegenerate": "指尖點與基準點重疊,請以 Alt 拖曳移動", "customImage": "自定義圖像", "configure": "配置", "className": "類名", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 90030e817..113abdbf5 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -576,6 +576,15 @@ "spriteTransition": "过渡时间", "spriteEasing": "动效", "spriteEasingCustom": "自定义", + "spriteActivation": "反应方式", + "spriteActivationHold": "按住保持", + "spriteActivationPress": "单次播放", + "spritePressDuration": "播放时间", + "spriteContactPoint": "指尖", + "spritePinLock": "固定指尖", + "spriteStretch": "伸展", + "spriteContactKnobHint": "拖动:将指尖对准按键 · Alt 拖动:移动指尖点", + "spriteContactDegenerate": "指尖点与基准点重叠,请用 Alt 拖动移动", "customImage": "自定义图像", "configure": "配置", "className": "类名", diff --git a/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts index c9b3bd157..4e3aa6cc9 100644 --- a/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts +++ b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts @@ -4,6 +4,7 @@ import { useSpriteEditPreviewStore } from './useSpriteEditPreviewStore'; import type { SpritePose } from '@src/types/key/sprites'; const pose = (poseId: string): SpritePose => ({ + contactPoint: { x: 0.5, y: 1 }, poseId, triggers: ['k1'], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, diff --git a/src/renderer/stores/grid/useSpritePoseGizmoStore.ts b/src/renderer/stores/grid/useSpritePoseGizmoStore.ts new file mode 100644 index 000000000..ead4d2a90 --- /dev/null +++ b/src/renderer/stores/grid/useSpritePoseGizmoStore.ts @@ -0,0 +1,76 @@ +import { create } from 'zustand'; +import type { + SpriteAnchor, + SpriteRect, + SpriteTransform, +} from '@src/types/key/sprites'; + +/** + * 온캔버스 자세 기즈모 세션 - 자세 팝업이 열려 있는 동안 그리드 오버레이가 + * 어느 스프라이트의 어떤 자세를 편집 중인지 공유한다. + * 발행자는 SingleSpritePanel 하나, 소비자는 SpritePoseGizmo 하나 + */ + +export interface SpritePoseGizmoSession { + positionId: string; + poseId: string; + /** 활동 영역 원점 (그리드 좌표) */ + origin: { dx: number; dy: number }; + imageRect: SpriteRect; + pivot: SpriteAnchor; + contactPoint: SpriteAnchor; + transform: SpriteTransform; + /** 뻗기 - 노브 드래그가 rotation과 함께 scale까지 역산 */ + stretch: boolean; + preview: (transform: SpriteTransform) => void; + commit: (transform: SpriteTransform) => void; + cancel: () => void; + commitContactPoint: (point: SpriteAnchor) => void; +} + +interface SpritePoseGizmoState { + session: SpritePoseGizmoSession | null; + /** 소유권 세대 - 세션 종료·대상 교체 때 증가. 포인터 이벤트 사이에 대상이 + 갈려도 이전 드래그가 새 세션을 소유할 수 없다 (gradient 세션과 동일 규칙) */ + generation: number; + lastOwnerKey: string | null; + setSession: (session: SpritePoseGizmoSession | null) => void; + /** 리사이즈 착지 등 좌표 기준이 바뀔 때 세대만 올려 진행 중 드래그의 + 커밋을 무효화한다 - 세션 자체는 유지되고 다음 드래그는 새 세대를 잡는다 */ + invalidateOwnership: (positionId: string) => void; +} + +const ownerKey = (session: SpritePoseGizmoSession): string => + `${session.positionId}\n${session.poseId}`; + +export const useSpritePoseGizmoStore = create((set) => ({ + session: null, + generation: 0, + lastOwnerKey: null, + setSession: (session) => + set((state) => { + if (!session) { + return state.session + ? { + session: null, + lastOwnerKey: null, + generation: state.generation + 1, + } + : {}; + } + const key = ownerKey(session); + return key !== state.lastOwnerKey + ? { + session, + lastOwnerKey: key, + generation: state.generation + 1, + } + : { session }; + }), + invalidateOwnership: (positionId) => + set((state) => + state.session?.positionId === positionId + ? { generation: state.generation + 1 } + : {}, + ), +})); diff --git a/src/renderer/stores/signals/keySignals.ts b/src/renderer/stores/signals/keySignals.ts index 7d922d28b..19d4e8c73 100644 --- a/src/renderer/stores/signals/keySignals.ts +++ b/src/renderer/stores/signals/keySignals.ts @@ -18,4 +18,47 @@ export function setKeyActive(key: string, isActive: boolean) { export function resetAllKeySignals() { for (const s of keySignals.values()) s.value = false; + // 리셋은 전체 재구성 - 이벤트 레벨도 비워 다음 실제 DOWN이 edge가 되게 한다 + eventKeyDownStates.clear(); +} + +// 실제 입력 DOWN edge 구독 - 하이드레이션·리싱크의 시그널 세팅과 분리된 채널. +// 시그널 레벨(false→true)로 edge를 만들면 부트스트랩·OBS 재연결·탭 전환의 +// 재수화가 전부 유령 edge가 되므로, 이벤트 경로만 emit을 호출한다 +const pressEdgeListeners = new Map void>>(); + +export function subscribeKeyPressEdge( + key: string, + listener: () => void, +): () => void { + let listeners = pressEdgeListeners.get(key); + if (!listeners) { + listeners = new Set(); + pressEdgeListeners.set(key, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) pressEdgeListeners.delete(key); + }; +} + +export function emitKeyPressEdge(key: string) { + const listeners = pressEdgeListeners.get(key); + if (!listeners) return; + for (const listener of [...listeners]) listener(); +} + +// 이벤트 경로가 마지막으로 적용한 레벨 - edge 판정 전용. +// 시그널 값으로 판정하면 OBS 리싱크·하이드레이션이 지연 대기 중인 실제 DOWN의 +// 레벨을 선점해 edge를 삼킨다 (시그널은 표시용, 이 맵은 이벤트 이력용) +const eventKeyDownStates = new Map(); + +// 이벤트 경로 전용 세팅 - 이벤트 이력 기준 상승 전이에만 edge를 발화해 +// OS 반복 DOWN을 무시한다. 하이드레이션·리싱크는 setKeyActive를 그대로 쓴다 +export function applyEventKeyState(key: string, isDown: boolean) { + const rising = isDown && eventKeyDownStates.get(key) !== true; + eventKeyDownStates.set(key, isDown); + getKeySignal(key).value = isDown; + if (rising) emitKeyPressEdge(key); } diff --git a/src/renderer/utils/sprite/contactSolver.test.ts b/src/renderer/utils/sprite/contactSolver.test.ts new file mode 100644 index 000000000..fe8e9645c --- /dev/null +++ b/src/renderer/utils/sprite/contactSolver.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import { + contactWorldPosition, + solveTransformTowardTarget, + solveTranslationKeepingContact, + type ContactGeometry, +} from './contactSolver'; + +// 축(0.5,0.25)·핀(0.5,1) - 위에서 매달린 팔 형태, 기본 길이 150px +const geometry: ContactGeometry = { + imageRect: { x: 10, y: 20, width: 100, height: 200 }, + pivot: { x: 0.5, y: 0.25 }, + contactPoint: { x: 0.5, y: 1 }, +}; + +const identity = { x: 0, y: 0, rotation: 0, scale: 1 }; + +describe('contactWorldPosition', () => { + it('항등 변환에서는 핀의 rect 좌표 그대로다', () => { + expect(contactWorldPosition(geometry, identity)).toEqual({ + x: 60, + y: 220, + }); + }); + + it('이동·회전·배율을 축 기준으로 합성한다', () => { + // 90도 회전: 아래로 뻗은 벡터(0,150)가 왼쪽(-150,0)으로 눕는다 + const world = contactWorldPosition(geometry, { + x: 7, + y: -3, + rotation: 90, + scale: 2, + }); + expect(world.x).toBeCloseTo(7 + 60 - 300, 6); + expect(world.y).toBeCloseTo(-3 + 70, 6); + }); +}); + +describe('solveTransformTowardTarget (축 고정)', () => { + it('현재 핀 위치를 목표로 주면 transform이 보존된다 - 왕복 항등성', () => { + const current = { x: 12, y: -8, rotation: 33, scale: 1.4 }; + const world = contactWorldPosition(geometry, current); + const solved = solveTransformTowardTarget(geometry, current, world, true); + expect(solved.status).toBe('ok'); + if (solved.status !== 'ok') return; + expect(solved.transform.x).toBe(current.x); + expect(solved.transform.y).toBe(current.y); + expect(solved.transform.rotation).toBeCloseTo(current.rotation, 6); + expect(solved.transform.scale).toBeCloseTo(current.scale, 6); + }); + + it('역산된 transform은 핀을 목표에 정확히 올린다', () => { + const target = { x: -40, y: 90 }; + const solved = solveTransformTowardTarget(geometry, identity, target, true); + expect(solved.status).toBe('ok'); + if (solved.status !== 'ok') return; + const world = contactWorldPosition(geometry, solved.transform); + expect(world.x).toBeCloseTo(target.x, 6); + expect(world.y).toBeCloseTo(target.y, 6); + }); + + it('뻗기 OFF면 scale을 유지하고 방향각만 맞춘다', () => { + const solved = solveTransformTowardTarget( + geometry, + { ...identity, scale: 1.5 }, + { x: 300, y: 220 }, + false, + ); + expect(solved.status).toBe('ok'); + if (solved.status !== 'ok') return; + expect(solved.transform.scale).toBe(1.5); + // 핀은 호 위의 최근접점 - 목표 방향은 일치한다 + const axisWorld = { x: 60, y: 70 }; + const world = contactWorldPosition(geometry, solved.transform); + const targetAngle = Math.atan2(220 - axisWorld.y, 300 - axisWorld.x); + const worldAngle = Math.atan2(world.y - axisWorld.y, world.x - axisWorld.x); + expect(worldAngle).toBeCloseTo(targetAngle, 6); + }); + + it('scale·rotation은 계약 범위로 clamp된다', () => { + // 기본 길이 150px, 목표 거리 6000px -> scale 40은 상한 10으로 + const solved = solveTransformTowardTarget( + geometry, + identity, + { x: 60, y: 70 + 6000 }, + true, + ); + expect(solved.status).toBe('ok'); + if (solved.status !== 'ok') return; + expect(solved.transform.scale).toBe(10); + }); + + it('핀=축 퇴화는 transform을 건드리지 않고 degenerate를 반환한다', () => { + const degenerate = solveTransformTowardTarget( + { ...geometry, contactPoint: geometry.pivot }, + identity, + { x: 100, y: 100 }, + true, + ); + expect(degenerate).toEqual({ status: 'degenerate' }); + }); + + it('목표가 축 위인 퇴화도 degenerate다', () => { + const solved = solveTransformTowardTarget( + geometry, + identity, + { x: 60, y: 70 }, + true, + ); + expect(solved).toEqual({ status: 'degenerate' }); + }); +}); + +describe('solveTranslationKeepingContact (핀 고정)', () => { + it('회전이 바뀌어도 핀 월드 위치가 유지된다', () => { + const current = { x: 5, y: 5, rotation: 10, scale: 1.2 }; + const pinned = contactWorldPosition(geometry, current); + const solved = solveTranslationKeepingContact( + geometry, + { rotation: -70, scale: 1.2 }, + pinned, + ); + expect(solved.status).toBe('ok'); + if (solved.status !== 'ok') return; + const world = contactWorldPosition(geometry, solved.transform); + expect(world.x).toBeCloseTo(pinned.x, 6); + expect(world.y).toBeCloseTo(pinned.y, 6); + }); + + it('배율 변경도 핀을 고정한다', () => { + const pinned = contactWorldPosition(geometry, identity); + const solved = solveTranslationKeepingContact( + geometry, + { rotation: 0, scale: 2 }, + pinned, + ); + expect(solved.status).toBe('ok'); + if (solved.status !== 'ok') return; + const world = contactWorldPosition(geometry, solved.transform); + expect(world.x).toBeCloseTo(pinned.x, 6); + expect(world.y).toBeCloseTo(pinned.y, 6); + }); + + it('offset 상한에 걸리면 닿는 데까지만 간다', () => { + const solved = solveTranslationKeepingContact( + geometry, + { rotation: 180, scale: 10 }, + { x: 5000, y: 5000 }, + ); + expect(solved.status).toBe('ok'); + if (solved.status !== 'ok') return; + expect(solved.transform.x).toBeLessThanOrEqual(2000); + expect(solved.transform.y).toBeLessThanOrEqual(2000); + }); + + it('핀=축 퇴화는 degenerate다', () => { + const solved = solveTranslationKeepingContact( + { ...geometry, contactPoint: geometry.pivot }, + { rotation: 30, scale: 1 }, + { x: 0, y: 0 }, + ); + expect(solved).toEqual({ status: 'degenerate' }); + }); +}); diff --git a/src/renderer/utils/sprite/contactSolver.ts b/src/renderer/utils/sprite/contactSolver.ts new file mode 100644 index 000000000..8b30ae004 --- /dev/null +++ b/src/renderer/utils/sprite/contactSolver.ts @@ -0,0 +1,145 @@ +import type { + SpriteAnchor, + SpriteRect, + SpriteTransform, +} from '@src/types/key/sprites'; +import { SPRITE_CONSTRAINTS } from '@src/types/key/sprites'; + +// 핀(손끝) 기반 자세 역산 - 에디터 전용 순수 기하. +// 좌표계는 요소 로컬 px. transform은 translate → rotate → scale 순서이고 +// pivot(transform-origin)이 회전·배율의 축이므로 +// 손끝 월드 = t + A + R(θ)·s·(C − A) (A=축 px, C=핀 px) + +export interface ContactGeometry { + imageRect: SpriteRect; + /** 이미지 정규화 [0,1] - 회전·배율 축 */ + pivot: SpriteAnchor; + /** 이미지 정규화 [0,1] - 키에 닿는 지점 */ + contactPoint: SpriteAnchor; +} + +export type ContactSolveResult = + | { status: 'ok'; transform: SpriteTransform } + // 핀과 축이 같은 지점 - 방향·길이가 정의되지 않아 조작 불가 + | { status: 'degenerate' }; + +// 핀·축 거리, 목표 벡터가 이보다 짧으면 방향이 정의되지 않은 것으로 본다 +export const CONTACT_EPSILON = 1e-6; + +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +// atan2 차(-360~360도)를 최단 호 표현(-180~180]으로 접는다 +const wrapDegrees = (deg: number): number => { + const wrapped = ((deg + 540) % 360) - 180; + return wrapped === -180 ? 180 : wrapped; +}; + +const anchorPx = ( + rect: SpriteRect, + anchor: SpriteAnchor, +): { x: number; y: number } => ({ + x: rect.x + anchor.x * rect.width, + y: rect.y + anchor.y * rect.height, +}); + +/** 현재 transform이 적용된 핀의 요소 로컬 위치 */ +export const contactWorldPosition = ( + geometry: ContactGeometry, + transform: SpriteTransform, +): { x: number; y: number } => { + const axis = anchorPx(geometry.imageRect, geometry.pivot); + const contact = anchorPx(geometry.imageRect, geometry.contactPoint); + const rad = transform.rotation * DEG_TO_RAD; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = (contact.x - axis.x) * transform.scale; + const dy = (contact.y - axis.y) * transform.scale; + return { + x: transform.x + axis.x + dx * cos - dy * sin, + y: transform.y + axis.y + dx * sin + dy * cos, + }; +}; + +/** + * 축 고정 역산 - x·y를 유지한 채 핀이 목표를 향하도록 rotation을, + * stretch면 scale까지 계산한다. 몸에 붙은 팔을 키에 뻗는 조작 + */ +export const solveTransformTowardTarget = ( + geometry: ContactGeometry, + current: SpriteTransform, + target: { x: number; y: number }, + stretch: boolean, +): ContactSolveResult => { + const axis = anchorPx(geometry.imageRect, geometry.pivot); + const contact = anchorPx(geometry.imageRect, geometry.contactPoint); + const vx = contact.x - axis.x; + const vy = contact.y - axis.y; + const baseLength = Math.hypot(vx, vy); + if (baseLength <= CONTACT_EPSILON) return { status: 'degenerate' }; + + // 축의 월드 위치(x·y 유지)에서 목표까지의 벡터 + const gx = target.x - (current.x + axis.x); + const gy = target.y - (current.y + axis.y); + const targetLength = Math.hypot(gx, gy); + if (targetLength <= CONTACT_EPSILON) return { status: 'degenerate' }; + + const rotation = wrapDegrees( + (Math.atan2(gy, gx) - Math.atan2(vy, vx)) * RAD_TO_DEG, + ); + const { scale, rotation: rotationRange } = SPRITE_CONSTRAINTS; + return { + status: 'ok', + transform: { + ...current, + rotation: clamp(rotation, rotationRange.min, rotationRange.max), + scale: stretch + ? clamp(targetLength / baseLength, scale.min, scale.max) + : current.scale, + }, + }; +}; + +/** + * 핀 고정 역산 - rotation·scale이 바뀌어도 핀 월드 위치가 고정점에 남도록 + * x·y를 계산한다. 이미 키에 올린 손의 각도만 다듬는 조작. + * clamp에 걸리면 닿는 데까지만 간다 + */ +export const solveTranslationKeepingContact = ( + geometry: ContactGeometry, + next: Pick, + contactWorld: { x: number; y: number }, +): ContactSolveResult => { + const axis = anchorPx(geometry.imageRect, geometry.pivot); + const contact = anchorPx(geometry.imageRect, geometry.contactPoint); + const vx = contact.x - axis.x; + const vy = contact.y - axis.y; + if (Math.hypot(vx, vy) <= CONTACT_EPSILON) return { status: 'degenerate' }; + + const rad = next.rotation * DEG_TO_RAD; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = vx * next.scale; + const dy = vy * next.scale; + const { offset } = SPRITE_CONSTRAINTS; + return { + status: 'ok', + transform: { + rotation: next.rotation, + scale: next.scale, + x: clamp( + contactWorld.x - axis.x - (dx * cos - dy * sin), + offset.min, + offset.max, + ), + y: clamp( + contactWorld.y - axis.y - (dx * sin + dy * cos), + offset.min, + offset.max, + ), + }, + }; +}; diff --git a/src/renderer/utils/sprite/poseResolver.test.ts b/src/renderer/utils/sprite/poseResolver.test.ts index 78e03cebf..1136a5686 100644 --- a/src/renderer/utils/sprite/poseResolver.test.ts +++ b/src/renderer/utils/sprite/poseResolver.test.ts @@ -24,6 +24,7 @@ const makePose = ( transform: Partial = {}, imageOverride: string | null = null, ): SpritePose => ({ + contactPoint: { x: 0.5, y: 1 }, poseId, triggers, transform: makeTransform(transform), @@ -34,6 +35,8 @@ const makeSprite = ( poses: SpritePose[], overrides: Partial = {}, ): ReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, dx: 0, dy: 0, width: 200, diff --git a/src/renderer/utils/sprite/spriteReach.test.ts b/src/renderer/utils/sprite/spriteReach.test.ts index 42579e013..1c76f4875 100644 --- a/src/renderer/utils/sprite/spriteReach.test.ts +++ b/src/renderer/utils/sprite/spriteReach.test.ts @@ -29,6 +29,7 @@ const makePose = ( transform: Partial = {}, imageOverride: string | null = null, ): SpritePose => ({ + contactPoint: { x: 0.5, y: 1 }, poseId, triggers: [poseId], transform: makeTransform(transform), diff --git a/src/renderer/utils/sprite/spriteWireShape.test.ts b/src/renderer/utils/sprite/spriteWireShape.test.ts index 2b77bae73..65d5bdeec 100644 --- a/src/renderer/utils/sprite/spriteWireShape.test.ts +++ b/src/renderer/utils/sprite/spriteWireShape.test.ts @@ -8,6 +8,7 @@ import type { import { toSpriteWireShape } from './spriteWireShape'; const basePose = (overrides: Partial = {}): SpritePose => ({ + contactPoint: { x: 0.5, y: 1 }, poseId: 'pose-1', triggers: ['key-1'], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, @@ -16,6 +17,8 @@ const basePose = (overrides: Partial = {}): SpritePose => ({ }); const basePosition = (): ReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: 'sprite-1', dx: 0, dy: 0, diff --git a/src/renderer/windows/overlay/App.spritePreview.test.tsx b/src/renderer/windows/overlay/App.spritePreview.test.tsx index 9cd01ad95..efffca5a0 100644 --- a/src/renderer/windows/overlay/App.spritePreview.test.tsx +++ b/src/renderer/windows/overlay/App.spritePreview.test.tsx @@ -134,6 +134,8 @@ const SPRITE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const SESSION_ID = '00000000-0000-4000-8000-0000000000aa'; const spritePosition = (dx: number): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, id: SPRITE_ID, dx, dy: 25, diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index 8b1a783b2..d4be54049 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -32,6 +32,7 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useSpriteStore } from '@stores/data/useSpriteStore'; import { + applyEventKeyState, setKeyActive as setKeyActiveSignal, resetAllKeySignals, } from '@stores/signals/keySignals'; @@ -484,11 +485,12 @@ export default function App() { }); timerEntry.timers.clear(); keyDelayTimersRef.current.delete(key); - setKeyActiveSignal(key, isDown); + // 이벤트 경로만 press edge를 발화한다 - 하이드레이션·리싱크는 유령 edge 방지 + applyEventKeyState(key, isDown); return; } - const apply = () => setKeyActiveSignal(key, isDown); + const apply = () => applyEventKeyState(key, isDown); const timer = setTimeout(() => { const pendingApply = pendingKeyDelayTimersRef.current.get(timer); if (!pendingApply) return; diff --git a/src/types/key/sprites.test.ts b/src/types/key/sprites.test.ts index 0b0f31e97..92e8f4719 100644 --- a/src/types/key/sprites.test.ts +++ b/src/types/key/sprites.test.ts @@ -22,6 +22,8 @@ const backendWireSprite = { imageRect: { height: 200, width: 200, x: 0, y: 0 }, pivot: { x: 0.5, y: 0.5 }, poses: [], + activation: 'whileHeld', + pressDurationMs: 300, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', transitionMs: 90, useInlineStyles: null, @@ -66,6 +68,7 @@ describe('reactiveSpritePositionSchema wire 계약', () => { triggers: ['566b0333-494c-4d47-a76d-506b71e5ac4c'], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, ...pose, }, ], @@ -168,6 +171,7 @@ describe('reactiveSpritePositionSchema wire 계약', () => { describe('findDuplicateTriggerPose', () => { const pose = (poseId: string, triggers: string[]): SpritePose => ({ + contactPoint: { x: 0.5, y: 1 }, poseId, triggers, transform: { x: 0, y: 0, rotation: 0, scale: 1 }, diff --git a/src/types/key/sprites.ts b/src/types/key/sprites.ts index ee1fd1906..b2e1cee46 100644 --- a/src/types/key/sprites.ts +++ b/src/types/key/sprites.ts @@ -13,8 +13,13 @@ export const SPRITE_CONSTRAINTS = { scale: { min: 0.1, max: 10 }, anchor: { min: 0, max: 1 }, transitionMs: { min: 0, max: 1000 }, + // 카운터 애니메이션과 같은 범위 - 백엔드 검증과 동일 + pressDurationMs: { min: 1, max: 5000 }, // 백엔드 MAX_ABS_COORDINATE/MAX_DIMENSION과 동일 imageRect: { coordMin: -32768, coordMax: 32768, dimensionMax: 32768 }, + // 리사이즈 배율 언더플로가 치수 검증(0 초과)을 깨지 않게 하는 하한. + // 백엔드 SPRITE_RESIZE_MIN_DIMENSION과 동일 + resizeMinDimension: 0.000001, maxPoses: 64, // 백엔드 MAX_SPRITE_POSE_TRIGGERS와 동일 maxTriggersPerPose: 512, @@ -111,6 +116,31 @@ export const CENTER_SPRITE_ANCHOR: SpriteAnchor = Object.freeze({ y: 0.5, }); +// 자세 이미지의 키 접점(핀) 기본값 - 손 이미지 기준 바닥 중앙. +// 백엔드 default_sprite_contact_point와 동일 +export const DEFAULT_SPRITE_CONTACT_POINT: SpriteAnchor = Object.freeze({ + x: 0.5, + y: 1, +}); + +// 키 반응 방식: 누르는 동안 유지 vs 누른 순간 한 번 재생. +// 백엔드 SpriteActivation과 동일 camelCase 문자열 +export const spriteActivationSchema = z.union([ + z.literal('whileHeld'), + z.literal('onPress'), +]); +export type SpriteActivation = z.infer; + +export const DEFAULT_SPRITE_ACTIVATION: SpriteActivation = 'whileHeld'; +export const DEFAULT_SPRITE_PRESS_DURATION_MS = 300; + +// Rust u32 - 소수는 decode 거부라 정수로 고정 +const spritePressDurationSchema = z + .number() + .int() + .min(SPRITE_CONSTRAINTS.pressDurationMs.min) + .max(SPRITE_CONSTRAINTS.pressDurationMs.max); + // 백엔드 normalize_sprite_triggers와 동일한 canonical 정규형(정렬+중복 제거). // ASCII UUID만 통과하므로 JS 사전순과 Rust 바이트순이 갈리지 않는다 export const normalizeSpriteTriggers = ( @@ -135,6 +165,9 @@ export const spritePoseSchema = z.object({ poseId: z.string().min(1), triggers: z.array(z.string()), ...spritePoseBaseShape, + // 손끝 고정 도우미의 핀 - 자세 이미지 기준이라 자세 레벨. + // 서빙은 BE serde default가 항상 채운다 + contactPoint: spriteAnchorSchema, }); export type SpritePose = z.infer; @@ -146,6 +179,8 @@ export const spritePoseInputSchema = z.object({ .array(z.string().min(1).max(SPRITE_CONSTRAINTS.triggerIdMaxLength)) .max(SPRITE_CONSTRAINTS.maxTriggersPerPose), ...spritePoseBaseShape, + // 구 플러그인 patch는 필드 자체가 없다 - 생략 허용, BE serde default가 채움 + contactPoint: spriteAnchorSchema.optional(), }); export type SpritePoseInput = z.infer; @@ -185,6 +220,9 @@ const reactiveSpritePositionBaseShape = { export const reactiveSpritePositionSchema = z.object({ ...reactiveSpritePositionBaseShape, + // 서빙은 BE serde default가 항상 채우므로 필수 + activation: spriteActivationSchema, + pressDurationMs: spritePressDurationSchema, poses: z.array(spritePoseSchema), }); export type ReactiveSpritePosition = z.infer< @@ -193,6 +231,9 @@ export type ReactiveSpritePosition = z.infer< export const reactiveSpritePositionInputSchema = z.object({ ...reactiveSpritePositionBaseShape, + // 구 플러그인 patch 호환 - 생략 허용, BE serde default가 채움 + activation: spriteActivationSchema.optional(), + pressDurationMs: spritePressDurationSchema.optional(), poses: z.array(spritePoseInputSchema).max(SPRITE_CONSTRAINTS.maxPoses), }); export type ReactiveSpritePositionInput = z.infer< diff --git a/tests/fixtures/sprite-constraint-parity.json b/tests/fixtures/sprite-constraint-parity.json index 21008b69f..1462b2525 100644 --- a/tests/fixtures/sprite-constraint-parity.json +++ b/tests/fixtures/sprite-constraint-parity.json @@ -5,8 +5,13 @@ "anchor": { "min": 0, "max": 1 }, "transitionMs": { "min": 0, "max": 1000 }, "imageRect": { "coordMin": -32768, "coordMax": 32768, "dimensionMax": 32768 }, + "resizeMinDimension": 0.000001, "maxPoses": 64, "maxTriggersPerPose": 512, + "pressDurationMs": { "min": 1, "max": 5000 }, "defaultTransitionMs": 0, - "defaultTransitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + "defaultTransitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)", + "defaultActivation": "whileHeld", + "defaultPressDurationMs": 300, + "defaultContactPoint": { "x": 0.5, "y": 1 } } diff --git a/tests/sprite-constraint-parity.test.ts b/tests/sprite-constraint-parity.test.ts index d75b2c0b2..9e1ccb69c 100644 --- a/tests/sprite-constraint-parity.test.ts +++ b/tests/sprite-constraint-parity.test.ts @@ -3,6 +3,9 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { + DEFAULT_SPRITE_ACTIVATION, + DEFAULT_SPRITE_CONTACT_POINT, + DEFAULT_SPRITE_PRESS_DURATION_MS, DEFAULT_SPRITE_TRANSITION_EASING, DEFAULT_SPRITE_TRANSITION_MS, SPRITE_CONSTRAINTS, @@ -29,10 +32,15 @@ interface SpriteConstraintFixture { anchor: { min: number; max: number }; transitionMs: { min: number; max: number }; imageRect: { coordMin: number; coordMax: number; dimensionMax: number }; + resizeMinDimension: number; maxPoses: number; maxTriggersPerPose: number; + pressDurationMs: { min: number; max: number }; defaultTransitionMs: number; defaultTransitionEasing: string; + defaultActivation: string; + defaultPressDurationMs: number; + defaultContactPoint: { x: number; y: number }; } const fixture = JSON.parse( @@ -55,6 +63,8 @@ const baseSprite = () => ({ pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [] as unknown[], + activation: 'whileHeld', + pressDurationMs: 300, transitionMs: 90, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', }); @@ -64,6 +74,7 @@ const pose = (poseId: string, triggers: string[]) => ({ triggers, transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, }); describe('sprite constraint parity (fixture)', () => { @@ -86,10 +97,14 @@ describe('sprite constraint parity (fixture)', () => { expect(SPRITE_CONSTRAINTS.anchor).toEqual(fixture.anchor); expect(SPRITE_CONSTRAINTS.transitionMs).toEqual(fixture.transitionMs); expect(SPRITE_CONSTRAINTS.imageRect).toEqual(fixture.imageRect); + expect(SPRITE_CONSTRAINTS.resizeMinDimension).toBe( + fixture.resizeMinDimension, + ); expect(SPRITE_CONSTRAINTS.maxPoses).toBe(fixture.maxPoses); expect(SPRITE_CONSTRAINTS.maxTriggersPerPose).toBe( fixture.maxTriggersPerPose, ); + expect(SPRITE_CONSTRAINTS.pressDurationMs).toEqual(fixture.pressDurationMs); }); it('전환 기본값은 fixture와 일치한다 - easing은 바이트 단위 동일해야 프리셋 매칭이 산다', () => { @@ -97,6 +112,11 @@ describe('sprite constraint parity (fixture)', () => { expect(DEFAULT_SPRITE_TRANSITION_EASING).toBe( fixture.defaultTransitionEasing, ); + expect(DEFAULT_SPRITE_ACTIVATION).toBe(fixture.defaultActivation); + expect(DEFAULT_SPRITE_PRESS_DURATION_MS).toBe( + fixture.defaultPressDurationMs, + ); + expect(DEFAULT_SPRITE_CONTACT_POINT).toEqual(fixture.defaultContactPoint); }); it('transform 스칼라는 fixture 경계에서 통과하고 근소 초과에서 거부된다', () => { @@ -168,6 +188,44 @@ describe('sprite constraint parity (fixture)', () => { transitionMs: fixture.transitionMs.max + 1, }).success, ).toBe(false); + + for (const value of [ + fixture.pressDurationMs.min, + fixture.pressDurationMs.max, + ]) { + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + pressDurationMs: value, + }).success, + ).toBe(true); + } + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + pressDurationMs: fixture.pressDurationMs.min - 1, + }).success, + ).toBe(false); + expect( + reactiveSpritePositionSchema.safeParse({ + ...baseSprite(), + pressDurationMs: fixture.pressDurationMs.max + 1, + }).success, + ).toBe(false); + }); + + it('canonical은 새 필드를 요구하고 input은 생략을 허용한다 - 구 플러그인 patch 호환', () => { + const { + activation: _activation, + pressDurationMs: _pressDurationMs, + ...legacySprite + } = baseSprite(); + const { contactPoint: _contactPoint, ...legacyPose } = pose('p', ['t']); + const legacy = { ...legacySprite, poses: [legacyPose] }; + expect(reactiveSpritePositionSchema.safeParse(legacy).success).toBe(false); + expect(reactiveSpritePositionInputSchema.safeParse(legacy).success).toBe( + true, + ); }); it('input 스키마 컬렉션 상한은 fixture 값에서 통과하고 +1에서 거부된다', () => { From a19769a9e7e1f72a2e768f506c8a38176424a530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:02:55 +0900 Subject: [PATCH 027/125] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=99=9C=EB=8F=99=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=B8=B5=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=EB=B3=B5=EC=A0=9C=20=EA=B3=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 45 ++------- .../Grid/layers/SpriteDuplicateGhost.test.tsx | 93 +++++++++++++++++++ .../main/Grid/layers/SpriteDuplicateGhost.tsx | 72 ++++++++++++++ .../Grid/layers/SpriteItem.render.test.tsx | 53 +++++++++-- .../main/Grid/layers/SpriteItem.tsx | 45 +++++---- src/renderer/styles/tokens.css | 2 + .../utils/grid/activityAreaGuide.test.ts | 38 ++++++++ src/renderer/utils/grid/activityAreaGuide.ts | 22 +++++ 8 files changed, 309 insertions(+), 61 deletions(-) create mode 100644 src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx create mode 100644 src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx create mode 100644 src/renderer/utils/grid/activityAreaGuide.test.ts create mode 100644 src/renderer/utils/grid/activityAreaGuide.ts diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 73088e293..bc8e51392 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -62,7 +62,8 @@ import KeyCounterPreviewLayer from '../layers/KeyCounterPreviewLayer'; import StatCounterLayer from '../layers/StatCounterLayer'; import GraphItem from '../layers/GraphItem'; import KnobItem from '../layers/KnobItem'; -import SpriteItem, { ACTIVITY_AREA_BORDER } from '../layers/SpriteItem'; +import SpriteItem from '../layers/SpriteItem'; +import SpriteDuplicateGhost from '../layers/SpriteDuplicateGhost'; import { useGridSelectionStore, isElementInMarquee, @@ -97,7 +98,6 @@ import { imageTransformToCss, } from '@src/types/key/imageLayer'; import type { ReactiveSpritePosition } from '@src/types/key/sprites'; -import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; import { DEFAULT_ELEMENT_BG, DEFAULT_ELEMENT_FONT, @@ -1525,43 +1525,12 @@ const Grid = ({ if (!duplicateState || !duplicateCursor) return null; if (duplicateState.elementType === 'sprite') { - const spritePosition = duplicateState.position as ReactiveSpritePosition; - const width = spritePosition.width || 200; - const height = spritePosition.height || 200; - const offsetX = duplicateCursor.x - width / 2; - const offsetY = duplicateCursor.y - height / 2; - const ghostImage = resolveImageSource(spritePosition.baseImage); return ( -
- {ghostImage && ( - - )} -
+ ); } diff --git a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx new file mode 100644 index 000000000..273d5fbf4 --- /dev/null +++ b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx @@ -0,0 +1,93 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import SpriteDuplicateGhost from './SpriteDuplicateGhost'; +import { ACTIVITY_AREA_GUIDE_COLOR } from '@utils/grid/activityAreaGuide'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const position = ( + overrides: Partial = {}, +): ReactiveSpritePosition => + ({ + id: 'ghost-sprite', + dx: 0, + dy: 0, + width: 200, + height: 120, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: 'hand.png', + imageFit: 'contain', + imageRect: { x: 10, y: 20, width: 100, height: 80 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [], + activation: 'whileHeld', + pressDurationMs: 300, + transitionMs: 90, + transitionEasing: 'linear', + ...overrides, + } as ReactiveSpritePosition); + +let container: HTMLDivElement; +let root: Root; + +const renderGhost = (zoom = 1) => { + act(() => { + root.render( + , + ); + }); + return container.querySelector('[data-sprite-ghost="true"]'); +}; + +describe('SpriteDuplicateGhost', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('이미지 호스트에 보더를 두지 않아 놓는 순간 이미지가 튀지 않는다', () => { + // 보더가 있으면 absolute 이미지가 padding box 기준으로 밀려 + // 확정된 SpriteItem 위치와 어긋난다 + const ghost = renderGhost(); + expect(ghost).not.toBeNull(); + expect(ghost?.style.borderWidth).toBe(''); + expect(ghost?.style.borderTopStyle).toBe(''); + }); + + it('활동 영역 점선은 별도 층에 그리고 굵기를 줌으로 보정한다', () => { + renderGhost(4); + const guide = container.querySelector( + '[data-sprite-activity-guide="true"]', + ); + expect(guide?.style.borderTopStyle).toBe('dashed'); + expect(guide?.style.borderWidth).toBe('0.25px'); + expect(guide?.style.borderRadius).toBe('1px'); + // 아이템 가이드와 같은 토큰을 쓴다 + expect(guide?.style.borderColor).toBe(ACTIVITY_AREA_GUIDE_COLOR); + }); + + it('이미지 원점은 활동 영역 좌상단 기준이다 (아이템과 같은 규칙)', () => { + const image = renderGhost()?.querySelector('img'); + expect(image?.style.left).toBe('10px'); + expect(image?.style.top).toBe('20px'); + }); +}); diff --git a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx new file mode 100644 index 000000000..d1e1f8e5e --- /dev/null +++ b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx @@ -0,0 +1,72 @@ +import { resolveImageSource } from '@utils/core/imageSource'; +import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; +import { + ACTIVITY_AREA_GUIDE_COLOR, + activityAreaGuideMetrics, +} from '@utils/grid/activityAreaGuide'; +import { DEFAULT_SPRITE_SIZE } from '@src/types/key/sprites'; +import type { ReactiveSpritePosition } from '@src/types/key/sprites'; + +interface SpriteDuplicateGhostProps { + position: ReactiveSpritePosition; + /** 커서 위치 (그리드 좌표) - 고스트는 커서를 중심에 둔다 */ + cursor: { x: number; y: number }; + zoom: number; +} + +// 복제 배치 미리보기. 이미지 원점 규칙을 SpriteItem과 똑같이 맞춘다 - +// 호스트에 보더를 두면 안쪽 이미지가 밀려 놓는 순간 위치가 튄다 +const SpriteDuplicateGhost = ({ + position, + cursor, + zoom, +}: SpriteDuplicateGhostProps) => { + const width = position.width || DEFAULT_SPRITE_SIZE; + const height = position.height || DEFAULT_SPRITE_SIZE; + const ghostImage = resolveImageSource(position.baseImage); + + return ( +
+ {ghostImage && ( + + )} +
+
+ ); +}; + +export default SpriteDuplicateGhost; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index 9d686b325..9434d3158 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -85,12 +85,16 @@ const renderSprite = ( onPositionChange={() => {}} isSelected={options.isSelected ?? false} activeTool="move" + zoom={options.zoom ?? 1} />, ); }); return container.querySelector('[data-sprite-element="true"]'); }; +const activityGuide = () => + container.querySelector('[data-sprite-activity-guide="true"]'); + describe('SpriteItem 렌더', () => { beforeEach(() => { container = document.createElement('div'); @@ -104,12 +108,12 @@ describe('SpriteItem 렌더', () => { useSpriteEditPreviewStore.setState({ preview: null }); }); - it('선택 여부에 따라 활동 영역 테두리가 가이드와 선택 색을 오간다', () => { + it('활동 영역 가이드는 이미지 유무에 따라 상시·호버로 갈린다', () => { // 이미지 없는 스프라이트는 가이드가 유일한 실체 - 점선 상시 표시 const idle = renderSprite(spritePosition()); expect(idle).not.toBeNull(); - expect(idle?.className).toContain('border-dashed'); - expect(idle?.className).not.toContain('border-transparent'); + expect(activityGuide()?.className).toContain('border-dashed'); + expect(activityGuide()?.className).not.toContain('border-transparent'); expect(idle?.dataset.selected).toBeUndefined(); // 이미지가 없으면 자리표시자 렌더 expect( @@ -118,13 +122,32 @@ describe('SpriteItem 렌더', () => { // 이미지가 있으면 점선은 소음이라 기본 투명, 호버에만 드러난다. // 호버는 캡처를 받는 루트(group) 기준이라 눌린 동안에도 유지된다 - const withImage = renderSprite(spritePosition({ baseImage: 'hand.png' })); - expect(withImage?.className).toContain('border-transparent'); - expect(withImage?.className).toContain('group-hover/sprite:border-'); + renderSprite(spritePosition({ baseImage: 'hand.png' })); + expect(activityGuide()?.className).toContain('border-transparent'); + // 호버 색도 고스트와 같은 토큰 + expect(activityGuide()?.className).toContain( + 'group-hover/sprite:border-[color:var(--ui-guide-activity)]', + ); + }); + it('선택 상태는 공통 아웃라인이 전담해 내부 가이드를 그리지 않는다', () => { const selected = renderSprite(spritePosition(), { isSelected: true }); - expect(selected?.className).toContain('var(--ui-selection-border)'); expect(selected?.dataset.selected).toBe('true'); + // 그리드 아웃라인과 겹쳐 이중선이 되던 자리 - 아예 그리지 않는다 + expect(activityGuide()).toBeNull(); + }); + + it('이미지 호스트는 보더가 없어 오버레이와 같은 원점을 쓴다', () => { + // 보더가 있으면 absolute 이미지가 padding box 기준으로 1px 밀린다 + const host = renderSprite(spritePosition({ baseImage: 'hand.png' })); + expect(host?.style.borderWidth).toBe(''); + expect(host?.className).not.toContain('border'); + }); + + it('가이드 굵기와 radius는 줌에 반비례해 화면 px로 고정된다', () => { + renderSprite(spritePosition(), { zoom: 4 }); + expect(activityGuide()?.style.borderWidth).toBe('0.25px'); + expect(activityGuide()?.style.borderRadius).toBe('1px'); }); it('hidden 스프라이트는 렌더하지 않는다', () => { @@ -255,11 +278,27 @@ describe('SpriteItem 자세 편집 프리뷰', () => { expect(marker).not.toBeNull(); expect(marker?.style.left).toBe('35px'); expect(marker?.style.top).toBe('100px'); + expect(marker?.style.transform).toBe('translate(-50%, -50%) scale(1)'); act(() => useSpriteEditPreviewStore.getState().clear()); expect(node?.querySelector('[data-sprite-pivot-marker="true"]')).toBeNull(); }); + it('기준점 마커도 줌 보정으로 화면 크기를 유지한다', () => { + act(() => + useSpriteEditPreviewStore + .getState() + .publish({ kind: 'pivot', positionId: SPRITE_ID }), + ); + const node = renderSprite(spritePosition(), { zoom: 4 }); + + const marker = node?.querySelector( + '[data-sprite-pivot-marker="true"]', + ); + // 중심은 축 위치에 남고 크기만 줄어든다 + expect(marker?.style.transform).toBe('translate(-50%, -50%) scale(0.25)'); + }); + it('무효 draft 편집은 canonical 값 대신 스냅샷을 우선한다', () => { act(() => useSpriteEditPreviewStore.getState().publish({ diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index 0bd9014f1..35385352f 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -7,6 +7,10 @@ import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useSpriteEditPreview } from '@stores/grid/useSpriteEditPreviewStore'; import { resolveImageSource } from '@utils/core/imageSource'; import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; +import { + activityAreaGuideMetrics, + editorChromeScale, +} from '@utils/grid/activityAreaGuide'; import { DEFAULT_SPRITE_SIZE } from '@src/types/key/sprites'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; @@ -47,18 +51,14 @@ interface SpriteItemProps { } // 활동 영역은 에디터 전용 가이드 - 오버레이에는 그리지 않는다. -// 아이템 자체는 호버에만 점선을 보이고(아래 클래스, 같은 색), 이 상수는 복제 고스트 몫 -export const ACTIVITY_AREA_BORDER = '1px dashed rgba(237, 238, 242, 0.4)'; - // 이미지가 있으면 점선이 소음이라 호버에만, 없으면 가이드가 유일한 실체라 상시. -// 투명 보더를 유지해 호버 시 레이아웃이 밀리지 않는다. +// 선택 상태는 그리드 공통 아웃라인이 전담한다 - 여기서 또 그리면 이중선이 된다. // 호버 판정은 루트(group) 기준 - pointerdown 캡처가 자식 :hover를 끊어 // 클릭 순간 점선이 한 프레임 꺼지는 깜빡임을 막는다 const GUIDE_BORDER_CLASS = { - selected: 'border border-solid border-[var(--ui-selection-border)]', hoverOnly: - 'border border-dashed border-transparent group-hover/sprite:border-[rgba(237,238,242,0.4)]', - always: 'border border-dashed border-[rgba(237,238,242,0.4)]', + 'border-dashed border-transparent group-hover/sprite:border-[color:var(--ui-guide-activity)]', + always: 'border-dashed border-[color:var(--ui-guide-activity)]', } as const; // 캔버스의 스프라이트는 정적이다: 평소엔 idle 상태, 자세 팝업이 열려 있으면 @@ -265,18 +265,10 @@ const SpriteItem = ({ onDragStart={(e: React.DragEvent) => e.preventDefault()} >
)} + {isSelected ? null : ( + // 활동 영역 가이드 - 레이아웃에 참여하지 않는 별도 층이라 안쪽 이미지 + // 좌표를 밀지 않는다 (오버레이와 같은 원점). 이미지 위에 그려 가려지지 않는다 +
+ )} {showPivotMarker ? ( // 기준점 마커 - 회전·배율 축의 위치를 실시간으로 보여준다 (기준점 편집 동안)
{ + it('색은 토큰 한 곳에서 온다 - 아이템 클래스와 고스트 인라인이 같은 값을 쓴다', () => { + expect(ACTIVITY_AREA_GUIDE_COLOR).toBe('var(--ui-guide-activity)'); + }); + + it('배율 1에서는 원래 규격 그대로다', () => { + expect(activityAreaGuideMetrics(1)).toEqual({ + borderWidth: '1px', + borderRadius: '4px', + }); + }); + + it('확대하면 화면 굵기가 유지되도록 역수로 줄어든다', () => { + expect(activityAreaGuideMetrics(4)).toEqual({ + borderWidth: '0.25px', + borderRadius: '1px', + }); + expect(activityAreaGuideMetrics(0.5)).toEqual({ + borderWidth: '2px', + borderRadius: '8px', + }); + }); + + it('0 이하·비유한 배율은 무보정으로 방어한다', () => { + expect(editorChromeScale(0)).toBe(1); + expect(editorChromeScale(-2)).toBe(1); + expect(editorChromeScale(Number.NaN)).toBe(1); + expect(editorChromeScale(Number.POSITIVE_INFINITY)).toBe(1); + }); +}); diff --git a/src/renderer/utils/grid/activityAreaGuide.ts b/src/renderer/utils/grid/activityAreaGuide.ts new file mode 100644 index 000000000..0b5726a3e --- /dev/null +++ b/src/renderer/utils/grid/activityAreaGuide.ts @@ -0,0 +1,22 @@ +// 스프라이트 활동 영역 가이드의 시각 규격. 캔버스 아이템과 복제 고스트가 공유한다. +// 에디터 전용 크롬이라 줌과 무관하게 화면 px로 고정한다 (스마트 가이드와 같은 정책) - +// 줌 컨테이너 안에서 그려지므로 굵기·반지름을 배율의 역수로 보정한다 + +export const ACTIVITY_AREA_GUIDE_COLOR = 'var(--ui-guide-activity)'; + +const GUIDE_BORDER_WIDTH = 1; +const GUIDE_BORDER_RADIUS = 4; + +/** 줌 배율에서 화면 크기가 고정되는 보정 계수 */ +export const editorChromeScale = (zoom: number): number => + Number.isFinite(zoom) && zoom > 0 ? 1 / zoom : 1; + +export const activityAreaGuideMetrics = ( + zoom: number, +): { borderWidth: string; borderRadius: string } => { + const scale = editorChromeScale(zoom); + return { + borderWidth: `${GUIDE_BORDER_WIDTH * scale}px`, + borderRadius: `${GUIDE_BORDER_RADIUS * scale}px`, + }; +}; From 145b1428513000f9bb0b04c9c1be9de377da55bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:04:37 +0900 Subject: [PATCH 028/125] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=B9=84=EB=A1=80=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/models/editor.rs | 20 +- src-tauri/src/state/editor.rs | 26 +- src-tauri/src/state/editor_ops.rs | 567 +++++++++++++++++- src-tauri/src/state/editor_ops_parity.rs | 4 +- src-tauri/src/state/store.rs | 142 ++++- .../single/SingleSpritePanel.test.tsx | 122 ++++ .../single/SingleSpritePanel.tsx | 74 +++ .../editor/runtime/editorCoordinator.test.ts | 148 ++++- .../editor/runtime/editorCoordinator.ts | 34 ++ .../editor/runtime/elementOps.test.ts | 156 ++++- src/renderer/editor/runtime/elementOps.ts | 115 +++- .../editor/runtime/mixedBatchGeometry.test.ts | 13 +- .../editor/runtime/mixedBatchGeometry.ts | 28 +- .../hooks/Grid/useGridResize.test.tsx | 23 +- src/renderer/hooks/Grid/useGridResize.ts | 17 +- .../utils/sprite/resizeProjection.test.ts | 160 +++++ src/renderer/utils/sprite/resizeProjection.ts | 121 ++++ src/types/editor.ts | 38 +- tests/editor-ops-parity.test.ts | 3 +- tests/editor-property-wire-parity.test.ts | 4 +- tests/fixtures/editor-ops-parity.json | 295 ++++++++- tests/fixtures/editor-property-tags.json | 2 +- .../fixtures/sprite-resize-scale-parity.json | 161 +++++ tests/sprite-resize-scale-parity.test.ts | 86 +++ 24 files changed, 2272 insertions(+), 87 deletions(-) create mode 100644 src/renderer/utils/sprite/resizeProjection.test.ts create mode 100644 src/renderer/utils/sprite/resizeProjection.ts create mode 100644 tests/fixtures/sprite-resize-scale-parity.json create mode 100644 tests/sprite-resize-scale-parity.test.ts diff --git a/src-tauri/src/models/editor.rs b/src-tauri/src/models/editor.rs index 1db3f5fd7..bafd7c91c 100644 --- a/src-tauri/src/models/editor.rs +++ b/src-tauri/src/models/editor.rs @@ -9,7 +9,7 @@ use super::{ pub const EDITOR_SCHEMA_VERSION: u16 = 1; pub const EDITOR_COMMIT_SCHEMA_VERSION_V2: u16 = 2; -pub const EDITOR_OPS_VERSION: u16 = 2; +pub const EDITOR_OPS_VERSION: u16 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -543,6 +543,10 @@ pub enum EditorOpV1 { id: String, bounds: EditorBoundsV1, }, + ResizeSprite { + id: String, + bounds: EditorBoundsV1, + }, DeleteElement { #[serde(rename = "elementType")] element_type: EditorElementTypeV1, @@ -598,6 +602,7 @@ impl EditorOpV1 { .iter() .any(|element| matches!(element, EditorFrozenElementV1::Key { .. })), Self::SetBounds { .. } + | Self::ResizeSprite { .. } | Self::PatchElement { .. } | Self::ReorderElements { .. } | Self::SetElementGroups { .. } @@ -608,6 +613,7 @@ impl EditorOpV1 { pub fn target_id(&self) -> Option<&str> { match self { Self::SetBounds { id, .. } + | Self::ResizeSprite { id, .. } | Self::DeleteElement { id, .. } | Self::PatchElement { id, .. } | Self::SetKeySlot { id, .. } => Some(id), @@ -1650,5 +1656,17 @@ mod tests { }]), ) .may_change_keys()); + let sprite_id = uuid::Uuid::new_v4().to_string(); + let resize = EditorOpV1::ResizeSprite { + id: sprite_id.clone(), + bounds: EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 200.0, + height: 100.0, + }, + }; + assert_eq!(resize.target_id(), Some(sprite_id.as_str())); + assert!(!request(None, Some(vec![resize])).may_change_keys()); } } diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index ce9b6eb43..94ea4a079 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -2821,6 +2821,7 @@ mod tests { transition_ms: IntegerRangeFixture, press_duration_ms: IntegerRangeFixture, image_rect: ImageRectRangeFixture, + resize_min_dimension: f64, max_poses: usize, max_triggers_per_pose: usize, default_transition_ms: u32, @@ -4643,8 +4644,8 @@ mod tests { #[test] fn editor_ops_enforce_version_count_ids_and_global_target_uniqueness() { let id = Uuid::new_v4().to_string(); - // 구버전 1과 미래 버전 3 모두 거부, v1 이중 수용 없음 - for unsupported_version in [1, 3] { + // 구버전과 미래 버전 모두 거부, 다중 버전 수용 없음 + for unsupported_version in [1, 2, 4] { let mut unsupported = ops_request(vec![set_bounds_op(&id, EditorElementTypeV1::Key)]); unsupported.ops_version = Some(unsupported_version); assert_eq!( @@ -4690,6 +4691,23 @@ mod tests { Some("DUPLICATE_EDITOR_OP_TARGET") ); + let duplicate_resize = ops_request(vec![ + set_bounds_op(&id, EditorElementTypeV1::Key), + EditorOpV1::ResizeSprite { + id: id.clone(), + bounds: EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 200.0, + height: 100.0, + }, + }, + ]); + assert_eq!( + validation_code(&validate_request_envelope(&duplicate_resize).unwrap_err()), + Some("DUPLICATE_EDITOR_OP_TARGET") + ); + let nil = ops_request(vec![set_bounds_op( Uuid::nil().to_string(), EditorElementTypeV1::Key, @@ -6379,6 +6397,10 @@ mod tests { assert_eq!(fixture.image_rect.coord_min, -MAX_ABS_COORDINATE); assert_eq!(fixture.image_rect.coord_max, MAX_ABS_COORDINATE); assert_eq!(fixture.image_rect.dimension_max, MAX_DIMENSION); + assert_eq!( + fixture.resize_min_dimension, + crate::models::SPRITE_RESIZE_MIN_DIMENSION + ); for pivot in [ crate::models::SpriteAnchor { diff --git a/src-tauri/src/state/editor_ops.rs b/src-tauri/src/state/editor_ops.rs index ac0efccbb..7ed12622b 100644 --- a/src-tauri/src/state/editor_ops.rs +++ b/src-tauri/src/state/editor_ops.rs @@ -13,14 +13,17 @@ use crate::{ EditorPaintGradientV1, EditorShadowLeafPatchV1, EditorTargetGroupV1, EditorZUpdateV1, ElementShadowSpec, GradientSpec, ImageMode, ImageTransform, ImageTransformLeafPatchV1, KeyPosition, LayerGroupDef, NoteColor, NoteGradientShadow, ReactiveSpritePosition, - IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, IMAGE_TRANSFORM_ROTATION_MAX, - IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, IMAGE_TRANSFORM_SCALE_MIN, - SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, SHADOW_OFFSET_MIN, + SpriteTransform, IMAGE_TRANSFORM_OFFSET_MAX, IMAGE_TRANSFORM_OFFSET_MIN, + IMAGE_TRANSFORM_ROTATION_MAX, IMAGE_TRANSFORM_ROTATION_MIN, IMAGE_TRANSFORM_SCALE_MAX, + IMAGE_TRANSFORM_SCALE_MIN, SHADOW_BLUR_MAX, SHADOW_BLUR_MIN, SHADOW_OFFSET_MAX, + SHADOW_OFFSET_MIN, SPRITE_RESIZE_MIN_DIMENSION, SPRITE_TRANSFORM_OFFSET_MAX, + SPRITE_TRANSFORM_OFFSET_MIN, }, }; use super::editor::{ validate_document_transition, validate_editor_op_bounds, validate_editor_op_target_type, + MAX_ABS_COORDINATE, MAX_DIMENSION, }; use super::native_element_id::{validate_document_element_ids, DUPLICATE_ELEMENT_ID}; use super::plugin::{plugin_group_refs_from_store, PluginGroupRefs}; @@ -973,6 +976,22 @@ fn element_common_at_mut<'a>( }) } +fn sprite_at_mut<'a>( + document: &'a mut EditorDocumentV1, + location: &ElementLocation, +) -> Result<&'a mut ReactiveSpritePosition, EditorCommitError> { + document + .sprite_positions + .get_mut(&location.mode) + .and_then(|positions| positions.get_mut(location.index)) + .ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + }) +} + fn position_at_mut<'a>( document: &'a mut EditorDocumentV1, location: &ElementLocation, @@ -1590,6 +1609,75 @@ fn apply_bounds(common: ElementCommonMut<'_>, bounds: &EditorBoundsV1) { *common.height = bounds.height; } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpriteResizeValueField { + Offset, + Coordinate, + Dimension, +} + +#[allow(clippy::neg_cmp_op_on_partial_ord)] +fn sprite_resize_ratio(prev: f64, next: f64) -> f64 { + if !(prev > 0.0) || !prev.is_finite() || !(next > 0.0) || !next.is_finite() { + return 1.0; + } + let ratio = next / prev; + if ratio.is_finite() { + ratio + } else { + 1.0 + } +} + +fn scale_sprite_resize_value(value: f64, ratio: f64, field: SpriteResizeValueField) -> f64 { + // 배율 1은 클램프 없는 완전 passthrough - 순수 이동이 검증상 유효한 + // 극소 치수(하한 미만)까지 비트 단위로 보존한다 + if ratio == 1.0 { + return value; + } + let (min, max) = match field { + SpriteResizeValueField::Offset => { + (SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_OFFSET_MAX) + } + SpriteResizeValueField::Coordinate => (-MAX_ABS_COORDINATE, MAX_ABS_COORDINATE), + SpriteResizeValueField::Dimension => (SPRITE_RESIZE_MIN_DIMENSION, MAX_DIMENSION), + }; + (value * ratio).max(min).min(max) +} + +fn scale_sprite_transform_offsets(transform: &mut SpriteTransform, sx: f64, sy: f64) { + transform.x = scale_sprite_resize_value(transform.x, sx, SpriteResizeValueField::Offset); + transform.y = scale_sprite_resize_value(transform.y, sy, SpriteResizeValueField::Offset); +} + +fn apply_sprite_resize(sprite: &mut ReactiveSpritePosition, bounds: &EditorBoundsV1) { + let sx = sprite_resize_ratio(sprite.width, bounds.width); + let sy = sprite_resize_ratio(sprite.height, bounds.height); + + sprite.dx = bounds.dx; + sprite.dy = bounds.dy; + sprite.width = bounds.width; + sprite.height = bounds.height; + sprite.image_rect.x = + scale_sprite_resize_value(sprite.image_rect.x, sx, SpriteResizeValueField::Coordinate); + sprite.image_rect.y = + scale_sprite_resize_value(sprite.image_rect.y, sy, SpriteResizeValueField::Coordinate); + sprite.image_rect.width = scale_sprite_resize_value( + sprite.image_rect.width, + sx, + SpriteResizeValueField::Dimension, + ); + sprite.image_rect.height = scale_sprite_resize_value( + sprite.image_rect.height, + sy, + SpriteResizeValueField::Dimension, + ); + scale_sprite_transform_offsets(&mut sprite.idle_transform, sx, sy); + for pose in &mut sprite.poses { + scale_sprite_transform_offsets(&mut pose.transform, sx, sy); + } +} + fn delete_elements( document: &mut EditorDocumentV1, delete_ids: &HashMap>, @@ -1704,6 +1792,7 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( | EditorOpV1::PatchElement { element_type, id, .. } => Some((*element_type, id)), + EditorOpV1::ResizeSprite { id, .. } => Some((EditorElementTypeV1::Sprite, id)), EditorOpV1::SetKeySlot { id, .. } => { if let Some(location) = locations.get(id) { validate_editor_op_target_type( @@ -2182,6 +2271,34 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( bounds: element_bounds(&candidate, location), }); } + EditorOpV1::ResizeSprite { id, bounds } => { + let Some(location) = locations.get(id) else { + validate_editor_op_bounds(op_index, None, *bounds)?; + op_results.push(EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + }); + continue; + }; + + let current_bounds = element_bounds(&candidate, location).ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + })?; + validate_editor_op_bounds(op_index, Some(current_bounds), *bounds)?; + let status = if current_bounds == *bounds { + EditorOpResultStatusV1::NoChange + } else { + apply_sprite_resize(sprite_at_mut(&mut candidate, location)?, bounds); + EditorOpResultStatusV1::Applied + }; + op_results.push(EditorOpResultV1 { + status, + bounds: element_bounds(&candidate, location), + }); + } EditorOpV1::DeleteElement { element_type, id } => { let Some(location) = locations.get(id) else { op_results.push(EditorOpResultV1 { @@ -3125,7 +3242,8 @@ mod tests { EditorFrozenElementV1, EditorFrozenGroupV1, EditorFrozenKeySlotV1, EditorGroupUpdateV1, EditorOpResultStatusV1, EditorOpV1, EditorTargetGroupV1, EditorZUpdateV1, GraphPosition, GraphStatType, GraphType, KeyPosition, KnobPosition, LayerGroupDef, - ReactiveSpritePosition, SpritePose, StatPosition, StatType, + ReactiveSpritePosition, SpriteAnchor, SpritePose, SpriteRect, SpriteTransform, + StatPosition, StatType, }, state::native_element_id::{ backfill_store_element_ids, DUPLICATE_ELEMENT_ID, INVALID_ELEMENT_ID, @@ -3134,6 +3252,124 @@ mod tests { use super::*; + const SPRITE_RESIZE_SCALE_PARITY_FIXTURE: &str = + include_str!("../../../tests/fixtures/sprite-resize-scale-parity.json"); + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct SpriteResizeScaleParityFixture { + version: u16, + comment: String, + ranges: SpriteResizeScaleParityRanges, + cases: Vec, + } + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct SpriteResizeScaleParityRanges { + offset: [f64; 2], + coord: [f64; 2], + dimension: [f64; 2], + } + + #[derive(Debug, Clone, Copy, serde::Deserialize)] + #[serde(rename_all = "lowercase")] + enum SpriteResizeScaleParityField { + Ratio, + Offset, + Coord, + Dimension, + } + + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct SpriteResizeScaleParityCase { + name: String, + field: SpriteResizeScaleParityField, + prev: f64, + next: f64, + value: Option, + expected: f64, + expected_hex: String, + } + + fn resize_sprite_op(id: impl Into, bounds: EditorBoundsV1) -> EditorOpV1 { + EditorOpV1::ResizeSprite { + id: id.into(), + bounds, + } + } + + fn sprite_bounds(position: &ReactiveSpritePosition) -> EditorBoundsV1 { + EditorBoundsV1 { + dx: position.dx, + dy: position.dy, + width: position.width, + height: position.height, + } + } + + #[test] + fn sprite_resize_scale_math_matches_shared_fixture_bits() { + let fixture: SpriteResizeScaleParityFixture = + serde_json::from_str(SPRITE_RESIZE_SCALE_PARITY_FIXTURE).unwrap(); + assert_eq!(fixture.version, 1); + assert!(!fixture.comment.is_empty()); + assert_eq!( + fixture.ranges.offset, + [SPRITE_TRANSFORM_OFFSET_MIN, SPRITE_TRANSFORM_OFFSET_MAX] + ); + assert_eq!( + fixture.ranges.coord, + [-MAX_ABS_COORDINATE, MAX_ABS_COORDINATE] + ); + assert_eq!( + fixture.ranges.dimension, + [SPRITE_RESIZE_MIN_DIMENSION, MAX_DIMENSION] + ); + + for case in fixture.cases { + let expected_bits = u64::from_str_radix( + case.expected_hex + .strip_prefix("0x") + .expect("expectedHex must use the 0x prefix"), + 16, + ) + .expect("expectedHex must contain IEEE754 f64 bits"); + assert_eq!( + case.expected.to_bits(), + expected_bits, + "case {} fixture decimal and expectedHex", + case.name + ); + let ratio = sprite_resize_ratio(case.prev, case.next); + let actual = match case.field { + SpriteResizeScaleParityField::Ratio => ratio, + SpriteResizeScaleParityField::Offset => scale_sprite_resize_value( + case.value.expect("scaled case must contain value"), + ratio, + SpriteResizeValueField::Offset, + ), + SpriteResizeScaleParityField::Coord => scale_sprite_resize_value( + case.value.expect("scaled case must contain value"), + ratio, + SpriteResizeValueField::Coordinate, + ), + SpriteResizeScaleParityField::Dimension => scale_sprite_resize_value( + case.value.expect("scaled case must contain value"), + ratio, + SpriteResizeValueField::Dimension, + ), + }; + assert_eq!( + actual.to_bits(), + expected_bits, + "case {} result bits", + case.name + ); + } + } + fn base_store() -> AppStoreData { let mut store = AppStoreData { keys: default_keys().clone(), @@ -3838,6 +4074,329 @@ mod tests { assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); } + #[test] + fn resize_sprite_wire_uses_only_kind_id_and_bounds() { + let id = uuid::Uuid::new_v4().to_string(); + let bounds = EditorBoundsV1 { + dx: 1.0, + dy: 2.0, + width: 300.0, + height: 150.0, + }; + let op = resize_sprite_op(&id, bounds); + assert_eq!( + serde_json::to_value(&op).unwrap(), + serde_json::json!({ + "kind": "resizeSprite", + "id": id, + "bounds": { + "dx": 1.0, + "dy": 2.0, + "width": 300.0, + "height": 150.0, + }, + }) + ); + assert!(serde_json::from_value::(serde_json::json!({ + "kind": "resizeSprite", + "id": uuid::Uuid::new_v4().to_string(), + "bounds": { + "dx": 1.0, + "dy": 2.0, + "width": 300.0, + "height": 150.0, + }, + "elementType": "sprite", + })) + .is_err()); + } + + #[test] + fn resize_sprite_scales_uniform_and_nonuniform_content() { + let mut store = store_with_sprite(); + let position = &mut store.sprite_positions.get_mut("4key").unwrap()[0]; + position.dx = 10.0; + position.dy = 20.0; + position.width = 200.0; + position.height = 100.0; + position.image_rect = SpriteRect { + x: 40.0, + y: -10.0, + width: 160.0, + height: 80.0, + }; + position.pivot = SpriteAnchor { x: 0.25, y: 0.75 }; + position.idle_transform = SpriteTransform { + x: 12.0, + y: -6.0, + rotation: 15.0, + scale: 1.5, + }; + position.poses = vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -30.0, + y: 44.0, + rotation: -90.0, + scale: 0.5, + }, + contact_point: SpriteAnchor { x: 0.5, y: 1.0 }, + ..SpritePose::default() + }]; + let original = position.clone(); + let id = position.id.clone(); + + let nonuniform_bounds = EditorBoundsV1 { + dx: 5.0, + dy: 8.0, + width: 400.0, + height: 50.0, + }; + let nonuniform = + prepare_editor_ops_transition(&store, &[resize_sprite_op(&id, nonuniform_bounds)]) + .unwrap(); + let resized = &nonuniform.candidate.sprite_positions["4key"][0]; + assert_eq!(sprite_bounds(resized), nonuniform_bounds); + assert_eq!( + resized.image_rect, + SpriteRect { + x: 80.0, + y: -5.0, + width: 320.0, + height: 40.0, + } + ); + assert_eq!( + (resized.idle_transform.x, resized.idle_transform.y), + (24.0, -3.0) + ); + assert_eq!( + (resized.poses[0].transform.x, resized.poses[0].transform.y), + (-60.0, 22.0) + ); + assert_eq!( + resized.idle_transform.rotation, + original.idle_transform.rotation + ); + assert_eq!(resized.idle_transform.scale, original.idle_transform.scale); + assert_eq!( + resized.poses[0].transform.rotation, + original.poses[0].transform.rotation + ); + assert_eq!( + resized.poses[0].transform.scale, + original.poses[0].transform.scale + ); + assert_eq!(resized.pivot, original.pivot); + assert_eq!( + resized.poses[0].contact_point, + original.poses[0].contact_point + ); + assert_eq!( + nonuniform.op_results[0].status, + EditorOpResultStatusV1::Applied + ); + assert_eq!(nonuniform.op_results[0].bounds, Some(nonuniform_bounds)); + + let uniform_bounds = EditorBoundsV1 { + dx: -5.0, + dy: -8.0, + width: 400.0, + height: 200.0, + }; + let uniform = + prepare_editor_ops_transition(&store, &[resize_sprite_op(id, uniform_bounds)]).unwrap(); + let resized = &uniform.candidate.sprite_positions["4key"][0]; + assert_eq!( + resized.image_rect, + SpriteRect { + x: 80.0, + y: -20.0, + width: 320.0, + height: 160.0, + } + ); + assert_eq!( + (resized.idle_transform.x, resized.idle_transform.y), + (24.0, -12.0) + ); + assert_eq!( + (resized.poses[0].transform.x, resized.poses[0].transform.y), + (-60.0, 88.0) + ); + } + + #[test] + fn resize_sprite_clamps_scaled_fields_at_contract_boundaries() { + let mut store = store_with_sprite(); + let position = &mut store.sprite_positions.get_mut("4key").unwrap()[0]; + position.width = 1.0; + position.height = 1.0; + position.image_rect = SpriteRect { + x: 2.0, + y: -2.0, + width: 2.0, + height: 0.000_000_000_001, + }; + position.idle_transform = SpriteTransform { + x: 1.0, + y: -1.0, + ..SpriteTransform::default() + }; + position.poses = vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -1.0, + y: 1.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }]; + let id = position.id.clone(); + let bounds = EditorBoundsV1 { + dx: MAX_ABS_COORDINATE, + dy: -MAX_ABS_COORDINATE, + width: MAX_DIMENSION, + height: MAX_DIMENSION, + }; + + let transition = + prepare_editor_ops_transition(&store, &[resize_sprite_op(id, bounds)]).unwrap(); + let resized = &transition.candidate.sprite_positions["4key"][0]; + assert_eq!(resized.image_rect.x, MAX_ABS_COORDINATE); + assert_eq!(resized.image_rect.y, -MAX_ABS_COORDINATE); + assert_eq!(resized.image_rect.width, MAX_DIMENSION); + assert_eq!(resized.image_rect.height, SPRITE_RESIZE_MIN_DIMENSION); + assert_eq!(resized.idle_transform.x, SPRITE_TRANSFORM_OFFSET_MAX); + assert_eq!(resized.idle_transform.y, SPRITE_TRANSFORM_OFFSET_MIN); + assert_eq!(resized.poses[0].transform.x, SPRITE_TRANSFORM_OFFSET_MIN); + assert_eq!(resized.poses[0].transform.y, SPRITE_TRANSFORM_OFFSET_MAX); + } + + #[test] + fn resize_sprite_defends_zero_previous_dimensions_with_identity_ratios() { + let mut position = ReactiveSpritePosition { + width: 0.0, + height: 0.0, + image_rect: SpriteRect { + x: 3.0, + y: -4.0, + width: 5.0, + height: 6.0, + }, + idle_transform: SpriteTransform { + x: 7.0, + y: -8.0, + ..SpriteTransform::default() + }, + poses: vec![SpritePose { + transform: SpriteTransform { + x: 9.0, + y: -10.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }; + let content_before = ( + position.image_rect, + position.idle_transform, + position.poses.clone(), + ); + let bounds = EditorBoundsV1 { + dx: 11.0, + dy: 12.0, + width: 100.0, + height: 200.0, + }; + + apply_sprite_resize(&mut position, &bounds); + + assert_eq!(sprite_bounds(&position), bounds); + assert_eq!( + (position.image_rect, position.idle_transform, position.poses,), + content_before + ); + } + + #[test] + fn resize_sprite_reports_missing_and_no_change_with_bounds_contract() { + let store = store_with_sprite(); + let position = &store.sprite_positions["4key"][0]; + let current_bounds = sprite_bounds(position); + let current_document = EditorDocumentV1::from_store(&store); + + let no_change = prepare_editor_ops_transition( + &store, + &[resize_sprite_op(&position.id, current_bounds)], + ) + .unwrap(); + assert_eq!(no_change.candidate, current_document); + assert_eq!( + no_change.op_results[0].status, + EditorOpResultStatusV1::NoChange + ); + assert_eq!(no_change.op_results[0].bounds, Some(current_bounds)); + + let missing = prepare_editor_ops_transition( + &store, + &[resize_sprite_op( + uuid::Uuid::new_v4().to_string(), + EditorBoundsV1 { + dx: 20.0, + dy: 30.0, + width: 400.0, + height: 250.0, + }, + )], + ) + .unwrap(); + assert_eq!(missing.candidate, current_document); + assert_eq!( + missing.op_results[0], + EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + } + ); + } + + #[test] + fn resize_sprite_type_mismatch_rejects_the_whole_mixed_batch() { + let store = base_store(); + let before = store.clone(); + let first_id = store.key_positions["4key"][0].id.clone(); + let second_id = store.key_positions["4key"][1].id.clone(); + let error = prepare_editor_ops_transition( + &store, + &[ + EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Key, + id: first_id, + bounds: EditorBoundsV1 { + dx: 100.0, + dy: 200.0, + width: 60.0, + height: 60.0, + }, + }, + resize_sprite_op( + second_id, + EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 300.0, + height: 150.0, + }, + ), + ], + ) + .unwrap_err(); + assert_eq!(validation_code(&error), Some("ELEMENT_TYPE_MISMATCH")); + assert_eq!(store, before); + } + #[test] fn sprite_set_bounds_updates_activity_bounds() { let store = store_with_sprite(); diff --git a/src-tauri/src/state/editor_ops_parity.rs b/src-tauri/src/state/editor_ops_parity.rs index 71b149223..1c21f316e 100644 --- a/src-tauri/src/state/editor_ops_parity.rs +++ b/src-tauri/src/state/editor_ops_parity.rs @@ -41,8 +41,9 @@ fn document_json(document: &EditorDocumentV1) -> String { // EditorOpV1 wire kind 기대 목록 - op_kind가 serde 실태그를 돌려주므로 // 집합 동등 테스트(fixture_covers_every_op_kind)가 이 목록의 오타도 잡는다 -const ALL_OP_KINDS: [&str; 8] = [ +const ALL_OP_KINDS: [&str; 9] = [ "setBounds", + "resizeSprite", "deleteElement", "patchElement", "setKeySlot", @@ -58,6 +59,7 @@ fn op_kind(op: &EditorOpV1) -> String { // 단일 원천은 serde 태그라 여기서 문자열을 복제하지 않는다 match op { EditorOpV1::SetBounds { .. } => {} + EditorOpV1::ResizeSprite { .. } => {} EditorOpV1::DeleteElement { .. } => {} EditorOpV1::PatchElement { .. } => {} EditorOpV1::SetKeySlot { .. } => {} diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index 78faefd25..af34c659b 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -4858,8 +4858,8 @@ mod tests { NoteColor, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, ReactiveSpritePosition, SavedPluginInstance, SettingsPatchInput, SlotMatch, - SoundLibraryEntry, SoundSource, SpritePose, StatPosition, StatType, TabCss, - TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, + SoundLibraryEntry, SoundSource, SpritePose, SpriteRect, SpriteTransform, StatPosition, + StatType, TabCss, TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, }, services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, @@ -5121,6 +5121,13 @@ mod tests { } } + fn resize_sprite_op(id: impl Into, bounds: EditorBoundsV1) -> EditorOpV1 { + EditorOpV1::ResizeSprite { + id: id.into(), + bounds, + } + } + fn delete_element_op(element_type: EditorElementTypeV1, id: impl Into) -> EditorOpV1 { EditorOpV1::DeleteElement { element_type, @@ -17161,6 +17168,137 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn resize_sprite_and_set_bounds_batch_undoes_as_one_history_entry() { + let dir = test_directory("resize-sprite-mixed-batch-history-test"); + std::fs::create_dir_all(&dir).unwrap(); + let mut data = initialize_default_state(); + data.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + dx: 10.0, + dy: 20.0, + width: 200.0, + height: 100.0, + image_rect: SpriteRect { + x: 40.0, + y: -10.0, + width: 160.0, + height: 80.0, + }, + idle_transform: SpriteTransform { + x: 12.0, + y: -6.0, + ..SpriteTransform::default() + }, + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -30.0, + y: 44.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }], + ); + crate::state::native_element_id::backfill_store_element_ids(&mut data); + let store = AppStore::new(dir.join("store.json"), data, false).unwrap(); + store.persist_current().unwrap(); + let initial = store.editor_get(); + let key = initial.document.key_positions["4key"][0].clone(); + let sprite = initial.document.sprite_positions["4key"][0].clone(); + let key_bounds = EditorBoundsV1 { + dx: key.dx + 50.0, + dy: key.dy + 25.0, + width: key.width, + height: key.height, + }; + let sprite_bounds = EditorBoundsV1 { + dx: 5.0, + dy: 8.0, + width: 400.0, + height: 50.0, + }; + + let change = store + .commit_editor_document(editor_ops_request( + initial.revision, + uuid::Uuid::new_v4().to_string(), + vec![ + resize_sprite_op(&sprite.id, sprite_bounds), + set_bounds_op(EditorElementTypeV1::Key, &key.id, key_bounds), + ], + )) + .unwrap(); + + assert_eq!(change.result.revision, initial.revision + 1); + assert_eq!( + change.result.changed_fields, + [EditorField::KeyPositions, EditorField::SpritePositions] + ); + assert_eq!( + change + .result + .op_results + .as_ref() + .unwrap() + .iter() + .map(|result| (result.status, result.bounds)) + .collect::>(), + vec![ + (EditorOpResultStatusV1::Applied, Some(sprite_bounds)), + (EditorOpResultStatusV1::Applied, Some(key_bounds)), + ] + ); + assert_eq!(store.history_status().history_revision, 1); + let committed = store.editor_get().document; + assert_eq!(bounds(&committed.key_positions["4key"][0]), key_bounds); + assert_eq!( + ( + committed.sprite_positions["4key"][0].dx, + committed.sprite_positions["4key"][0].dy, + committed.sprite_positions["4key"][0].width, + committed.sprite_positions["4key"][0].height, + ), + ( + sprite_bounds.dx, + sprite_bounds.dy, + sprite_bounds.width, + sprite_bounds.height, + ) + ); + assert_eq!( + committed.sprite_positions["4key"][0].image_rect, + SpriteRect { + x: 80.0, + y: -5.0, + width: 320.0, + height: 40.0, + } + ); + + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let counters = store.snapshot().key_counters; + let undo = store + .apply_history_operation(HistoryDirection::Undo, &operation_id, &counters, || {}) + .unwrap(); + drop(barrier); + + let restored = store.editor_get().document; + assert_eq!(restored.key_positions["4key"][0], key); + assert_eq!(restored.sprite_positions["4key"][0], sprite); + assert!(!undo.status.can_undo); + assert!(undo.status.can_redo); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn editor_ops_reject_type_mismatch_and_invalid_missing_bounds_atomically() { let dir = test_directory("editor-ops-atomic-validation-test"); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index 5de11abfc..17d3ca608 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -6,6 +6,8 @@ import { SingleSpritePanel } from './SingleSpritePanel'; import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; +import { useSpritePoseGizmoStore } from '@stores/grid/useSpritePoseGizmoStore'; +import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -1188,6 +1190,126 @@ describe('SingleSpritePanel 자세 편집', () => { ).toEqual([]); }); + it('리사이즈 착지 시 무효 draft 자세가 같은 배율로 rebase된다', async () => { + const position = spritePosition({ + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + poses: [ + { + contactPoint: { x: 0.5, y: 1 }, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + // 유일한 담당 키 해제 - 커밋이 막힌 무효 draft가 transform을 들고 있다 + await act(async () => triggerDropdownByText('A').click()); + await act(async () => menuOptionByText('A').click()); + expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ + preferFallback: true, + }); + + // 리사이즈 착지: canonical 콘텐츠가 projection 결과 그대로 도착 (sx=0.5, sy=2) + const resized = projectSpriteResize(position, { + dx: 0, + dy: 0, + width: 100, + height: 300, + }); + seed(resized); + render(resized); + + const preview = useSpriteEditPreviewStore.getState().preview; + expect( + preview?.kind === 'pose' ? preview.fallbackPose.transform : null, + ).toEqual({ x: 6, y: -12, rotation: 15, scale: 1.2 }); + expect( + preview?.kind === 'pose' ? preview.fallbackPose.triggers : null, + ).toEqual([]); + }); + + it('자세 팝업이 열린 채 리사이즈가 착지하면 진행 중 편집 게스처를 취소한다', async () => { + const position = spritePosition({ + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + poses: [ + { + contactPoint: { x: 0.5, y: 1 }, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + mocks.gestureCancel.mockClear(); + const popupBefore = container.querySelector( + '[data-testid="pose-popup"]', + ); + expect(popupBefore).not.toBeNull(); + const generationBefore = useSpritePoseGizmoStore.getState().generation; + + const resized = projectSpriteResize(position, { + dx: 0, + dy: 0, + width: 100, + height: 300, + }); + seed(resized); + render(resized); + + // 활성 게스처가 없으면 no-op이지만 취소 호출 자체는 착지마다 일어난다 + expect(mocks.gestureCancel).toHaveBeenCalled(); + // 팝업 리마운트 - 진행 중 스크럽 세션이 언마운트 취소로 닫힌다 + expect(popupBefore!.isConnected).toBe(false); + expect( + container.querySelector('[data-testid="pose-popup"]'), + ).not.toBeNull(); + // 기즈모 소유권 세대 무효화 - 진행 중 캔버스 드래그의 pointerup 커밋 차단 + expect(useSpritePoseGizmoStore.getState().generation).toBeGreaterThan( + generationBefore, + ); + }); + + it('박스만 바뀌는 변경은 draft 자세를 건드리지 않는다', async () => { + const position = spritePosition({ + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + poses: [ + { + contactPoint: { x: 0.5, y: 1 }, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + await act(async () => triggerDropdownByText('A').click()); + await act(async () => menuOptionByText('A').click()); + + // legacy patch 계열: 박스 치수만 바뀌고 imageRect·자세는 그대로 + const boxOnly = { ...position, width: 100, height: 300 }; + seed(boxOnly); + render(boxOnly); + + const preview = useSpriteEditPreviewStore.getState().preview; + expect( + preview?.kind === 'pose' ? preview.fallbackPose.transform : null, + ).toEqual({ x: 12, y: -6, rotation: 15, scale: 1.2 }); + }); + it('기준점 행을 만지는 동안 축 마커가 발행되고 벗어나면 회수된다', () => { const position = spritePosition(); seed(position); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index 9063ff2ec..e45ee43bf 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -4,10 +4,12 @@ import { editGestureController } from '@src/renderer/editor/runtime/editGestureC import { resolveElementById } from '@src/renderer/editor/model/elementIdMap'; import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import { useKeyStore } from '@stores/data/useKeyStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; import { spriteItemsApi } from '@api/modules/itemsApi'; import { imageApi } from '@api/modules/resourceApi'; import { canDecodeImage } from '@utils/core/assetProbe'; import { isHTMLElementNode } from '@utils/dom/isElementNode'; +import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import { slotDisplayName } from '@utils/keySlot'; import { ACTION_BUTTON_CLASS, AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; @@ -168,6 +170,73 @@ export const SingleSpritePanel: React.FC = ({ setPosesDraft(null); } + // 리사이즈 착지 시 draft rebase - canonical 콘텐츠가 이전 스냅샷의 projection과 + // 정확히 일치하면(리사이즈 정산) 무효 draft의 자세도 같은 배율로 따라간다. + // 판정은 합성 prop이 아니라 스토어 기준 - 활성 스크럽 프리뷰가 prop의 poses를 + // 덮어도 착지 감지가 빗나가지 않는다. 박스만 바뀌는 legacy patch는 imageRect가 + // 어긋나 제외되고, undo 복원은 왕복 오차로 불일치할 수 있어 건드리지 않는다 + const storePosition = useSpriteStore( + (state: { positions: Record }) => + state.positions[selectedKeyType]?.find( + (candidate) => candidate.id === position.id, + ) ?? null, + ); + const canonicalPosition = storePosition ?? position; + const [lastPositionSnapshot, setLastPositionSnapshot] = + useState(canonicalPosition); + const [resizeGestureCancelTick, setResizeGestureCancelTick] = useState(0); + if (lastPositionSnapshot !== canonicalPosition) { + setLastPositionSnapshot(canonicalPosition); + if ( + lastPositionSnapshot.id === canonicalPosition.id && + (lastPositionSnapshot.width !== canonicalPosition.width || + lastPositionSnapshot.height !== canonicalPosition.height) + ) { + const nextBounds = { + dx: canonicalPosition.dx, + dy: canonicalPosition.dy, + width: canonicalPosition.width, + height: canonicalPosition.height, + }; + const projected = projectSpriteResize(lastPositionSnapshot, nextBounds); + const resizeLanded = + JSON.stringify(projected.imageRect) === + JSON.stringify(canonicalPosition.imageRect) && + JSON.stringify(projected.idleTransform) === + JSON.stringify(canonicalPosition.idleTransform) && + JSON.stringify(projected.poses) === + JSON.stringify(canonicalPosition.poses); + if (resizeLanded) { + if (posesDraft && posesDraft.id === canonicalPosition.id) { + setPosesDraft({ + id: posesDraft.id, + poses: projectSpriteResize( + { ...lastPositionSnapshot, poses: posesDraft.poses }, + nextBounds, + ).poses, + }); + } + // 편집 팝업(자세·이미지 설정)이 열린 채 착지하면 세대를 올린다 - + // 팝업 리마운트가 진행 중 스크럽 세션을 취소로 닫고, 아래 effect가 + // preview 게스처를 정산해 이전 배율의 절대값 커밋을 차단한다 + if (editorTarget && editorTarget.positionId === canonicalPosition.id) { + setResizeGestureCancelTick((tick) => tick + 1); + } + } + } + } + + // 게스처 취소는 렌더 밖에서 - cancel은 활성 게스처가 없으면 no-op이라 + // 정산이 이미 끝난 일반 경로에는 영향이 없다 (계약의 세션 정산 폴백). + // 기즈모는 세대 무효화로 진행 중 드래그의 pointerup 커밋을 떨군다 + useLayoutEffect(() => { + if (resizeGestureCancelTick === 0) return; + editGestureController.cancel(); + useSpritePoseGizmoStore.getState().invalidateOwnership(position.id); + // 착지 시점의 스프라이트가 대상 - id는 세션 수명 동안 불변 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resizeGestureCancelTick]); + // 담당 키 후보: 현재 모드의 키 요소 목록 (값은 요소 id, 라벨은 슬롯 표시명) const modeSlots = keyMappings[selectedKeyType] ?? []; const keyOptions = (canonicalKeyPositions[selectedKeyType] ?? []).flatMap( @@ -1190,6 +1259,9 @@ export const SingleSpritePanel: React.FC = ({ {imageEditing ? ( = ({ /> ) : editingPose ? ( - op.kind === 'setBounds' + op.kind === 'setBounds' || op.kind === 'resizeSprite' ? { status: changedFields.length > 0 ? 'applied' : 'noChange', @@ -367,6 +368,26 @@ const applyOpsForTest = ( if (groups) next.layerGroups = groups; return; } + if (op.kind === 'resizeSprite') { + const spriteRecord = next.spritePositions as Record< + string, + Array> + >; + Object.entries(spriteRecord).some(([mode, positions]) => { + const index = positions.findIndex((position) => position.id === op.id); + if (index < 0) return false; + spriteRecord[mode] = positions.map((position, positionIndex) => + positionIndex === index + ? (projectSpriteResize(position as never, op.bounds) as Record< + string, + unknown + >) + : position, + ); + return true; + }); + return; + } const record = next[fields[op.elementType]] as Record< string, Array> @@ -1477,7 +1498,7 @@ describe('EditorSaveCoordinator', () => { async (context) => { expect(context).toMatchObject({ // 프론트만 승격되는 사고를 잡는 anchor - 상수 참조로 바꾸지 말 것 - editorOpsVersion: 2, + editorOpsVersion: 3, editorOps: [expect.objectContaining({ id })], }); return { @@ -3248,6 +3269,129 @@ describe('commitSemanticOpsInternal', () => { patch: { property: 'hidden', value: hidden }, }); + const RESIZE_SPRITE_ID = '00000000-0000-4000-8000-0000000000c1'; + const withResizableSprite = (): CanonicalEditorDocumentV1 => { + const document = makeDocument(); + document.spritePositions = { + '4key': [ + { + id: RESIZE_SPRITE_ID, + dx: 10, + dy: 20, + width: 200, + height: 100, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { x: 40, y: -10, width: 160, height: 80 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, + poses: [ + { + poseId: '00000000-0000-4000-8000-0000000000c2', + triggers: [DEFAULT_KEY_ID], + transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, + imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, + }, + ], + activation: 'whileHeld', + pressDurationMs: 300, + transitionMs: 90, + transitionEasing: 'linear', + } as never, + ], + }; + return document; + }; + + const resizeSpriteOp = (): EditorOpV1 => ({ + kind: 'resizeSprite', + id: RESIZE_SPRITE_ID, + bounds: { dx: 5, dy: 8, width: 400, height: 50 }, + }); + + it('resizeSprite는 bounds 교체와 콘텐츠 스케일을 함께 적용하고 재전송은 noChange다', async () => { + const harness = createHarness(withResizableSprite()); + await harness.coordinator.start(); + + const applied = await harness.coordinator.commitSemanticOpsInternal([ + resizeSpriteOp(), + ]); + expect(applied.opResults).toEqual([ + { status: 'applied', bounds: { dx: 5, dy: 8, width: 400, height: 50 } }, + ]); + // sx=2, sy=0.5 + const sprite = applied.document.spritePositions['4key'][0]; + expect(sprite).toMatchObject({ dx: 5, dy: 8, width: 400, height: 50 }); + expect(sprite.imageRect).toEqual({ x: 80, y: -5, width: 320, height: 40 }); + expect(sprite.idleTransform).toEqual({ + x: 24, + y: -3, + rotation: 15, + scale: 1.5, + }); + expect(sprite.poses[0].transform).toEqual({ + x: -60, + y: 22, + rotation: -90, + scale: 0.5, + }); + expect(sprite.pivot).toEqual({ x: 0.5, y: 0.5 }); + + const noChange = await harness.coordinator.commitSemanticOpsInternal([ + resizeSpriteOp(), + ]); + expect(noChange.opResults).toEqual([ + { status: 'noChange', bounds: { dx: 5, dy: 8, width: 400, height: 50 } }, + ]); + expect(noChange.document).toEqual(applied.document); + harness.coordinator.stop(); + }); + + it('resizeSprite conflict 재시도는 최신 base 콘텐츠에 배율을 재적용한다', async () => { + const harness = createHarness(withResizableSprite()); + await harness.coordinator.start(); + + harness.transport.commitMock.mockImplementationOnce(async () => { + // 외부 writer가 같은 스프라이트의 자세를 먼저 편집 (last-writer-wins 계약) + const external = structuredClone(harness.transport.canonical.document); + external.spritePositions['4key'][0].poses[0].transform = { + x: 100, + y: 44, + rotation: -90, + scale: 0.5, + }; + harness.transport.canonical.document = external; + harness.transport.canonical.revision += 1; + throw revisionConflict(); + }); + + const outcome = await harness.coordinator.commitSemanticOpsInternal([ + resizeSpriteOp(), + ]); + + // 재시도가 외부 편집이 반영된 base에서 배율을 다시 계산한다: 100*2=200 → clamp 없음 + const sprite = outcome.document.spritePositions['4key'][0]; + expect(sprite).toMatchObject({ dx: 5, dy: 8, width: 400, height: 50 }); + expect(sprite.poses[0].transform).toEqual({ + x: 200, + y: 22, + rotation: -90, + scale: 0.5, + }); + expect(sprite.imageRect).toEqual({ x: 80, y: -5, width: 320, height: 40 }); + // canonical과 낙관 로컬이 같은 결과로 수렴 + expect( + harness.transport.canonical.document.spritePositions['4key'][0], + ).toEqual(sprite); + expect(harness.getLocal().spritePositions['4key'][0]).toEqual(sprite); + harness.coordinator.stop(); + }); + it('fixed invalid ops는 Promise로 거절하고 transport start 전에 중단한다', async () => { const harness = createHarness(makeDocument()); const invalid = [{ kind: 'setBounds' }] as unknown as EditorOpV1[]; diff --git a/src/renderer/editor/runtime/editorCoordinator.ts b/src/renderer/editor/runtime/editorCoordinator.ts index 9b7336e6e..92f46c542 100644 --- a/src/renderer/editor/runtime/editorCoordinator.ts +++ b/src/renderer/editor/runtime/editorCoordinator.ts @@ -27,6 +27,7 @@ import { DEFAULT_ELEMENT_ACTIVE_SHADOW_SPEC, DEFAULT_ELEMENT_SHADOW_SPEC, } from '@utils/core/elementDefaults'; +import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import { isEditorShadowPropertyPatchV1 } from '@src/types/editor'; import { @@ -303,6 +304,7 @@ const fieldsForSemanticOp = (op: EditorOpV1): EditorField[] => { return [SEMANTIC_POSITION_FIELDS[op.elementType]]; } if (op.kind === 'setKeySlot') return ['keys']; + if (op.kind === 'resizeSprite') return ['spritePositions']; const positionField = SEMANTIC_POSITION_FIELDS[op.elementType]; if (op.kind === 'setBounds') return [positionField]; return op.elementType === 'key' ? ['keys', 'keyPositions'] : [positionField]; @@ -1032,6 +1034,24 @@ const applySemanticOps = ( } return; } + if (op.kind === 'resizeSprite') { + if (result?.status === 'noChange') return; + const positionsByMode = next.spritePositions; + for (const [mode, positions] of Object.entries(positionsByMode)) { + const index = positions.findIndex((position) => position.id === op.id); + if (index < 0) continue; + // 결과 bounds(서버 확정)가 있으면 그것으로 projection - 배율 수학은 + // resizeProjection 하나가 양측 계약을 소유한다 + const bounds = result?.bounds ?? op.bounds; + positionsByMode[mode] = positions.map((position, positionIndex) => + positionIndex === index + ? projectSpriteResize(position, bounds) + : position, + ); + break; + } + return; + } const field = SEMANTIC_POSITION_FIELDS[op.elementType]; const positionsByMode = next[field] as Record< string, @@ -1117,6 +1137,20 @@ const frozenOpCasUnit = ( height: entry.height, }; } + if (op.kind === 'resizeSprite') { + const entry = findPositionEntryById(document, 'sprite', op.id); + if (!entry) return null; + // resize가 소유하는 조각 전체 - bounds와 스케일 대상 콘텐츠 + return { + dx: entry.dx, + dy: entry.dy, + width: entry.width, + height: entry.height, + imageRect: entry.imageRect, + idleTransform: entry.idleTransform, + poses: entry.poses, + }; + } if (op.kind === 'patchElement') { return findPositionEntryById(document, op.elementType, op.id); } diff --git a/src/renderer/editor/runtime/elementOps.test.ts b/src/renderer/editor/runtime/elementOps.test.ts index 087d1f0cc..d3a126268 100644 --- a/src/renderer/editor/runtime/elementOps.test.ts +++ b/src/renderer/editor/runtime/elementOps.test.ts @@ -199,7 +199,7 @@ describe('elementOps', () => { return { document: documentFromStores(), opResults: _ops.map((op) => - op.kind === 'setBounds' + op.kind === 'setBounds' || op.kind === 'resizeSprite' ? { status: 'applied', bounds: op.bounds } : { status: 'applied' }, ), @@ -215,7 +215,7 @@ describe('elementOps', () => { return { document: documentFromStores(), opResults: ops.map((op) => - op.kind === 'setBounds' + op.kind === 'setBounds' || op.kind === 'resizeSprite' ? { status: 'applied', bounds: op.bounds } : { status: 'applied' }, ), @@ -728,7 +728,7 @@ describe('elementOps', () => { ]); }); - it('배치 정렬은 key+sprite 혼합 대상 모두 setBounds로 움직인다', async () => { + it('배치 정렬의 sprite는 resizeSprite로 이동한다 (치수 불변이라 배율 1)', async () => { const sprite = { ...spriteAt(ID_B), dx: 50, dy: 10, width: 20, height: 30 }; useKeyStore.setState({ canonicalPositions: { @@ -760,8 +760,7 @@ describe('elementOps', () => { bounds: { dx: 60, dy: 0, width: 10, height: 20 }, }, { - kind: 'setBounds', - elementType: 'sprite', + kind: 'resizeSprite', id: ID_B, bounds: { dx: 50, dy: 10, width: 20, height: 30 }, }, @@ -770,6 +769,63 @@ describe('elementOps', () => { expect(useKeyStore.getState().canonicalPositions['4key'][0].dx).toBe(60); }); + it('배치 resize의 sprite는 resizeSprite + projection eager로 콘텐츠까지 스케일한다', async () => { + const sprite = { + ...spriteAt(ID_B), + dx: 50, + dy: 10, + width: 200, + height: 120, + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, + }; + useKeyStore.setState({ + canonicalPositions: { + '4key': [{ ...keyAt(ID_A), dx: 10, dy: 0, width: 10, height: 20 }], + }, + positions: { + '4key': [{ ...keyAt(ID_A), dx: 10, dy: 0, width: 10, height: 20 }], + }, + }); + useSpriteStore.setState({ positions: { '4key': [sprite] } }); + api.lastAck = documentFromStores(); + + await commitBatchGeometryByIds({ + mode: '4key', + targets: [ + { type: 'key', id: ID_A }, + { type: 'sprite', id: ID_B }, + ], + operation: { kind: 'resize', dimension: 'width', value: 400 }, + }); + + expect(api.commitGeneratedSemanticOps).toHaveBeenCalledOnce(); + const generate = api.commitGeneratedSemanticOps.mock.calls[0][0]; + expect(generate(documentFromStores())).toEqual([ + { + kind: 'setBounds', + elementType: 'key', + id: ID_A, + bounds: { dx: 10, dy: 0, width: 400, height: 20 }, + }, + { + kind: 'resizeSprite', + id: ID_B, + bounds: { dx: 50, dy: 10, width: 400, height: 120 }, + }, + ]); + // eager: sx=2, sy=1로 스프라이트 콘텐츠 동반 스케일 + const eager = useSpriteStore.getState().positions['4key'][0]; + expect(eager).toMatchObject({ width: 400, height: 120 }); + expect(eager.imageRect).toEqual({ x: 8, y: 8, width: 192, height: 64 }); + expect(eager.idleTransform).toEqual({ + x: 24, + y: -6, + rotation: 15, + scale: 1.5, + }); + }); + it('배치 geometry는 slot 최신 base에서 전체 계획을 다시 계산한다', async () => { useKeyStore.setState({ canonicalPositions: { @@ -1420,6 +1476,96 @@ describe('elementOps', () => { }); }); + it('단일 sprite bounds는 resizeSprite op으로 콘텐츠까지 eager 스케일한다', async () => { + const sprite = { + ...spriteAt(ID_B), + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, + poses: [ + { + poseId: 'pose-1', + triggers: [ID_A], + transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, + imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, + }, + ], + }; + useSpriteStore.setState({ positions: { '4key': [sprite] } }); + + const committed = await commitSingleElementBoundsById( + 'sprite', + ID_B, + { dx: 5, dy: 8, width: 400, height: 60 }, + 'resize-gesture', + ); + + expect(committed).toBe(true); + // wire는 bounds만 - 배율은 백엔드가 최신 base 기준으로 재적용 + expect(api.commitSemanticOps).toHaveBeenCalledWith( + [ + { + kind: 'resizeSprite', + id: ID_B, + bounds: { dx: 5, dy: 8, width: 400, height: 60 }, + }, + ], + expect.objectContaining({ gestureId: 'resize-gesture' }), + ); + // eager: 200x120 → 400x60 (sx=2, sy=0.5) + const eager = useSpriteStore.getState().positions['4key'][0]; + expect(eager).toMatchObject({ dx: 5, dy: 8, width: 400, height: 60 }); + expect(eager.imageRect).toEqual({ x: 8, y: 4, width: 192, height: 32 }); + expect(eager.idleTransform).toEqual({ + x: 24, + y: -3, + rotation: 15, + scale: 1.5, + }); + expect(eager.poses[0].transform).toEqual({ + x: -60, + y: 22, + rotation: -90, + scale: 0.5, + }); + expect(eager.pivot).toBe(sprite.pivot); + expect(eager.poses[0].contactPoint).toEqual({ x: 0.5, y: 1 }); + }); + + it('sprite bounds의 편입 전 실패는 콘텐츠 필드까지 CAS 복원한다', async () => { + const sprite = { + ...spriteAt(ID_B), + imageRect: { x: 4, y: 8, width: 96, height: 64 }, + idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, + poses: [ + { + poseId: 'pose-1', + triggers: [ID_A], + transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, + imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, + }, + ], + }; + useSpriteStore.setState({ positions: { '4key': [sprite] } }); + api.commitSemanticOps.mockRejectedValue(new Error('start failed')); + + await expect( + commitSingleElementBoundsById('sprite', ID_B, { + dx: 5, + dy: 8, + width: 400, + height: 60, + }), + ).rejects.toThrow('start failed'); + + const restored = useSpriteStore.getState().positions['4key'][0]; + expect(restored).toMatchObject({ dx: 0, dy: 0, width: 200, height: 120 }); + expect(restored.imageRect).toEqual(sprite.imageRect); + expect(restored.idleTransform).toEqual(sprite.idleTransform); + expect(restored.poses).toEqual(sprite.poses); + }); + it('단일 bounds op의 targetMissing은 canonical 동기화 결과를 유지한다', async () => { api.commitSemanticOps.mockImplementationOnce(async (_ops, meta) => { meta?.onEnrolled?.(); diff --git a/src/renderer/editor/runtime/elementOps.ts b/src/renderer/editor/runtime/elementOps.ts index ed4f37fd3..1d38e598d 100644 --- a/src/renderer/editor/runtime/elementOps.ts +++ b/src/renderer/editor/runtime/elementOps.ts @@ -16,6 +16,7 @@ import { import { newElementId } from '../model/elementId'; import { cloneSlot } from '@utils/keySlot'; import { reissueSpritePoseIds } from '@utils/sprite/poseIdentity'; +import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import { stableStringify } from '@utils/core/stableStringify'; import { @@ -2438,18 +2439,11 @@ const planBatchGeometry = ( ); return { ...plan, + // 스프라이트는 배치에서도 resizeSprite - 치수가 변하는 연산(resize)은 + // 콘텐츠가 함께 스케일되고, 이동 계열은 배율 1이라 setBounds와 동일 ops: plan.bounds.flatMap(({ key, bounds }) => { const target = targetById.get(key); - return target - ? [ - { - kind: 'setBounds' as const, - elementType: target.type, - id: target.id, - bounds, - }, - ] - : []; + return target ? [elementBoundsOp(target.type, target.id, bounds)] : []; }), }; }; @@ -2473,15 +2467,24 @@ export const commitBatchGeometryByIds = ( const targetById = new Map( frozenDescriptor.targets.map((target) => [target.id, target] as const), ); + // 스프라이트 eager는 부분 patch가 아니라 full bounds로 projection해야 + // 콘텐츠 스케일까지 싣는다 + const fullBoundsByKey = new Map( + initialPlan.bounds.map(({ key, bounds }) => [key, bounds] as const), + ); for (const { key, patch } of initialPlan.updates) { const target = targetById.get(key); if (!target) continue; const byId = intents.get(target.type) ?? new Map(); - byId.set(target.id, patch as Record); + const spriteBounds = + target.type === 'sprite' ? fullBoundsByKey.get(key) : undefined; + byId.set( + target.id, + (spriteBounds ? { ...spriteBounds } : patch) as Record, + ); intents.set(target.type, byId); } - const receipt = - intents.size > 0 ? applyPropertyIntentsEagerly(intents) : null; + const receipt = intents.size > 0 ? applyBoundsIntentsEagerly(intents) : null; let enrolled = false; return commitGeneratedSemanticOps( (base) => planBatchGeometry(base, frozenDescriptor)?.ops ?? null, @@ -2572,6 +2575,64 @@ export const commitElementGeometryById = ( }); }; +// bounds 정산 op - 스프라이트는 bounds와 콘텐츠 스케일을 한 몸으로 갖는 +// resizeSprite, 나머지는 setBounds +export const elementBoundsOp = ( + elementType: NativeElementType, + id: string, + bounds: EditorBoundsV1, +): EditorOpV1 => + elementType === 'sprite' + ? { kind: 'resizeSprite', id, bounds } + : { kind: 'setBounds', elementType, id, bounds }; + +// 스프라이트 리사이즈 eager intent - 로컬 스토어의 현재 콘텐츠를 projection +// 으로 스케일해 bounds와 함께 싣는다. 백엔드는 최신 base에 같은 수학을 +// 재적용한다 (resizeProjection 하나가 양측 계약 소유). 로컬에 없으면 eager +// 적용 대상도 없으므로 bounds만 남긴다 +const spriteResizeEagerIntent = ( + id: string, + bounds: EditorBoundsV1, +): Record => { + const record = useSpriteStore.getState().positions ?? {}; + for (const positions of Object.values(record)) { + const current = positions?.find((position) => position.id === id); + if (current) { + const projected = projectSpriteResize(current, bounds); + return { + dx: projected.dx, + dy: projected.dy, + width: projected.width, + height: projected.height, + imageRect: projected.imageRect, + idleTransform: projected.idleTransform, + poses: projected.poses, + }; + } + } + return { ...bounds }; +}; + +// bounds intent의 eager 적용 - 스프라이트 항목은 projection 결과(콘텐츠 +// 포함)로 치환해 receipt가 스케일 필드까지 소유하게 한다 +export const applyBoundsIntentsEagerly = ( + intents: PropertyIntents, +): ElementIntentReceipt | null => { + let eagerIntents = intents; + const spriteIntents = intents.get('sprite'); + if (spriteIntents && spriteIntents.size > 0) { + const projectedById = new Map>(); + for (const [id, patch] of spriteIntents) { + const bounds = patch as unknown as EditorBoundsV1; + projectedById.set(id, spriteResizeEagerIntent(id, bounds)); + } + const next = new Map(intents); + next.set('sprite', projectedById); + eagerIntents = next; + } + return applyPropertyIntentsEagerly(eagerIntents); +}; + // 리사이즈 완료 전용: 시작 시 동결한 대상 id들에 최종 bounds를 하나의 // intent로 - eager와 wire, receipt를 같은 의도가 소유한다. 대상 소실은 // targetLost로 eager 복원, 오류는 전파 @@ -2602,22 +2663,19 @@ export const commitElementBoundsById = ( return Promise.resolve(false); } seenIds.add(id); - ops.push({ - kind: 'setBounds', - elementType, - id, - bounds: { + ops.push( + elementBoundsOp(elementType, id, { dx: patch.dx, dy: patch.dy, width: patch.width, height: patch.height, - }, - }); + }), + ); } } if (ops.length === 0) return Promise.resolve(false); - const receipt = applyPropertyIntentsEagerly(intents); + const receipt = applyBoundsIntentsEagerly(intents); let enrolled = false; return commitSemanticOps(ops, { ...(gestureId ? { gestureId } : {}), @@ -2654,18 +2712,15 @@ export const commitSingleElementBoundsById = ( const intents: PropertyIntents = new Map([ [type, new Map([[id, { ...bounds }]])], ]); - const receipt = applyPropertyIntentsEagerly(intents); + const receipt = applyBoundsIntentsEagerly(intents); let enrolled = false; - return commitSemanticOps( - [{ kind: 'setBounds', elementType: type, id, bounds }], - { - ...(gestureId ? { gestureId } : {}), - onEnrolled: () => { - enrolled = true; - }, + return commitSemanticOps([elementBoundsOp(type, id, bounds)], { + ...(gestureId ? { gestureId } : {}), + onEnrolled: () => { + enrolled = true; }, - ) + }) .then(({ opResults }) => opResults[0]?.status !== 'targetMissing') .catch((error) => { if (!enrolled) receipt?.rollback(); diff --git a/src/renderer/editor/runtime/mixedBatchGeometry.test.ts b/src/renderer/editor/runtime/mixedBatchGeometry.test.ts index 4d22eca39..b60699879 100644 --- a/src/renderer/editor/runtime/mixedBatchGeometry.test.ts +++ b/src/renderer/editor/runtime/mixedBatchGeometry.test.ts @@ -121,9 +121,16 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ }), })); -vi.mock('@src/renderer/editor/runtime/elementOps', () => ({ - commitBatchGeometryByIds: mocks.commitBatchGeometryByIds, -})); +vi.mock('@src/renderer/editor/runtime/elementOps', async (importOriginal) => { + // op 구성·eager projection은 실물, 배치 위임 경계만 mock + const actual = await importOriginal< + typeof import('@src/renderer/editor/runtime/elementOps') + >(); + return { + ...actual, + commitBatchGeometryByIds: mocks.commitBatchGeometryByIds, + }; +}); vi.mock('@src/renderer/editor/runtime/editorWriteBarrier', () => ({ trackEditorWrite: (promise: T) => promise, diff --git a/src/renderer/editor/runtime/mixedBatchGeometry.ts b/src/renderer/editor/runtime/mixedBatchGeometry.ts index 9a7bc407f..2bbcf537f 100644 --- a/src/renderer/editor/runtime/mixedBatchGeometry.ts +++ b/src/renderer/editor/runtime/mixedBatchGeometry.ts @@ -22,12 +22,13 @@ import { } from './batchGeometryPlan'; import { ElementIntentAbort, - applyPropertyIntentsEagerly, combineReceipts, type ElementIntentReceipt, } from './elementIntent'; import { + applyBoundsIntentsEagerly, commitBatchGeometryByIds, + elementBoundsOp, type BatchGeometryDescriptor, } from './elementOps'; import { runMixedGestureElementIntent } from './mixedElementIntent'; @@ -181,12 +182,9 @@ const planMixedBatchGeometry = ( for (const { key, bounds } of plan.bounds) { const native = targetByKey.get(key); if (native) { - nativeOps.push({ - kind: 'setBounds', - elementType: native.type, - id: native.id, - bounds, - }); + // 스프라이트는 resizeSprite - 이 경로는 치수 불변 연산만 통과하므로 + // 배율 1이지만 정산 경로 전체와 op 종류를 통일한다 + nativeOps.push(elementBoundsOp(native.type, native.id, bounds)); continue; } if (key.startsWith(PLUGIN_KEY_PREFIX)) { @@ -367,21 +365,31 @@ export const commitMixedBatchGeometry = ( rotatePluginInstancesEditSession(pluginId, gestureId), ); - // eager: native 기하 필드 CAS + 플러그인 position CAS + // eager: native 기하 필드 CAS + 플러그인 position CAS. + // 스프라이트는 full bounds projection이 콘텐츠까지 소유한다 const nativeIntents = new Map< NativeElementType, Map> >(); + const spriteBoundsById = new Map( + initialPlanned.nativeOps.flatMap((op) => + op.kind === 'resizeSprite' ? [[op.id, op.bounds] as const] : [], + ), + ); for (const { key, patch } of initialPlanned.updates) { const target = targetByKey.get(key); if (!target) continue; const byId = nativeIntents.get(target.type) ?? new Map(); - byId.set(target.id, patch); + const spriteBounds = + target.type === 'sprite' + ? spriteBoundsById.get(target.id) + : undefined; + byId.set(target.id, spriteBounds ? { ...spriteBounds } : patch); nativeIntents.set(target.type, byId); } const nativeReceipt = nativeIntents.size > 0 - ? applyPropertyIntentsEagerly(nativeIntents) + ? applyBoundsIntentsEagerly(nativeIntents) : null; let pluginReceipt: ElementIntentReceipt | null = null; try { diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index 961abe4b2..a0180bfe7 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -428,7 +428,7 @@ describe('useGridResize plugin gesture lifecycle', () => { ); }); - it('그룹 resize의 sprite는 활동 영역 박스만 커밋하고 imageRect·poses는 불변이다', async () => { + it('그룹 resize의 sprite는 resizeSprite op으로 bounds와 콘텐츠를 함께 스케일한다', async () => { const original = spriteAt(STABLE_SPRITE); useSpriteStore.setState({ positions: { '4key': [original] } }); mocks.elements = [{ fullId: 'plugin-a:one', pluginId: 'plugin-a' }]; @@ -459,15 +459,15 @@ describe('useGridResize plugin gesture lifecycle', () => { api.handleGroupResizeComplete(); }); - // wire: 박스 필드만 setBounds op으로 실린다 + // wire: sprite는 resizeSprite op - bounds만 실리고 배율은 백엔드가 최신 + // base 기준으로 재적용한다 expect(mocks.commitMixedGesture).toHaveBeenCalledWith( pluginGestureIds[0], { opsVersion: EDITOR_OPS_VERSION, ops: [ { - kind: 'setBounds', - elementType: 'sprite', + kind: 'resizeSprite', id: STABLE_SPRITE, bounds: { dx: 10, dy: 20, width: 100, height: 60 }, }, @@ -476,13 +476,20 @@ describe('useGridResize plugin gesture lifecycle', () => { ['plugin-a'], expect.anything(), ); - // eager: 활동 영역만 바뀌고 요소 로컬 데이터는 참조까지 그대로다 + // eager: 200x120 → 100x60 (sx=sy=0.5)로 콘텐츠까지 비례 스케일. + // pivot·contactPoint는 정규화 좌표라 불변 (pivot은 참조까지 보존) const eager = useSpriteStore.getState().positions['4key'][0]; expect(eager).toMatchObject({ dx: 10, dy: 20, width: 100, height: 60 }); - expect(eager.imageRect).toBe(original.imageRect); + expect(eager.imageRect).toEqual({ x: 2, y: 4, width: 48, height: 32 }); + expect(eager.idleTransform).toEqual({ x: 0, y: 0, rotation: 0, scale: 1 }); + expect(eager.poses[0].transform).toEqual({ + x: 6, + y: -3, + rotation: 15, + scale: 1.2, + }); expect(eager.pivot).toBe(original.pivot); - expect(eager.poses).toBe(original.poses); - expect(eager.idleTransform).toBe(original.idleTransform); + expect(eager.poses[0].contactPoint).toEqual({ x: 0.5, y: 1 }); }); it('혼합 그룹의 native ID가 비정규면 plugin까지 함께 fail-close한다', async () => { diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index 13563f23d..c330c2c7b 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -1,6 +1,5 @@ import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import { - applyPropertyIntentsEagerly, reportElementOpError, reportElementOpSkipped, type ElementIntentReceipt, @@ -10,8 +9,10 @@ import type { EditorOpV1 } from '@src/types/editor'; import { runMixedElementOpsIntent } from '@src/renderer/editor/runtime/mixedElementIntent'; import { sendBridgeMessageBestEffort } from '@utils/plugin/bridgeMessages'; import { + applyBoundsIntentsEagerly, commitElementBoundsById, commitSingleElementBoundsById, + elementBoundsOp, } from '@src/renderer/editor/runtime/elementOps'; import { useEffect, useRef, useState } from 'react'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; @@ -979,7 +980,8 @@ export function useGridResize({ groupSettlement = { kind: 'intents', stableIntents: stableBoundsIntents, - receipt: applyPropertyIntentsEagerly(stableBoundsIntents), + // 스프라이트는 projection이 콘텐츠 스케일까지 eager·receipt에 싣는다 + receipt: applyBoundsIntentsEagerly(stableBoundsIntents), }; } @@ -1032,17 +1034,14 @@ export function useGridResize({ const ops: EditorOpV1[] = []; for (const [elementType, byId] of settlement.stableIntents) { for (const [id, bounds] of byId) { - ops.push({ - kind: 'setBounds', - elementType, - id, - bounds: { + ops.push( + elementBoundsOp(elementType, id, { dx: bounds.dx as number, dy: bounds.dy as number, width: bounds.width as number, height: bounds.height as number, - }, - }); + }), + ); } } void runMixedElementOpsIntent({ diff --git a/src/renderer/utils/sprite/resizeProjection.test.ts b/src/renderer/utils/sprite/resizeProjection.test.ts new file mode 100644 index 000000000..7b21d447e --- /dev/null +++ b/src/renderer/utils/sprite/resizeProjection.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; + +import { + isSameSpriteBounds, + projectSpriteResize, + spriteResizeRatio, +} from './resizeProjection'; + +const basePosition = () => ({ + id: 'sprite-1', + dx: 10, + dy: 20, + width: 200, + height: 100, + imageRect: { x: 40, y: -10, width: 160, height: 80 }, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, + poses: [ + { + poseId: 'pose-1', + transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, + contactPoint: { x: 0.5, y: 1 }, + }, + ], + transitionMs: 90, +}); + +describe('projectSpriteResize', () => { + it('bounds 교체와 함께 imageRect·transform 오프셋을 축별 배율로 스케일한다', () => { + const next = projectSpriteResize(basePosition(), { + dx: 5, + dy: 8, + width: 400, + height: 50, + }); + expect(next.dx).toBe(5); + expect(next.dy).toBe(8); + expect(next.width).toBe(400); + expect(next.height).toBe(50); + // sx=2, sy=0.5 + expect(next.imageRect).toEqual({ x: 80, y: -5, width: 320, height: 40 }); + expect(next.idleTransform).toEqual({ + x: 24, + y: -3, + rotation: 15, + scale: 1.5, + }); + expect(next.poses[0].transform).toEqual({ + x: -60, + y: 22, + rotation: -90, + scale: 0.5, + }); + }); + + it('rotation·scale·pivot·contactPoint와 나머지 필드는 불변이다', () => { + const position = basePosition(); + const next = projectSpriteResize(position, { + dx: 0, + dy: 0, + width: 100, + height: 50, + }); + expect(next.pivot).toEqual(position.pivot); + expect(next.poses[0].contactPoint).toEqual(position.poses[0].contactPoint); + expect(next.poses[0].poseId).toBe('pose-1'); + expect(next.id).toBe(position.id); + expect(next.transitionMs).toBe(90); + }); + + it('동일 bounds면 원본 참조를 그대로 반환한다 (noChange)', () => { + const position = basePosition(); + const next = projectSpriteResize(position, { + dx: 10, + dy: 20, + width: 200, + height: 100, + }); + expect(next).toBe(position); + }); + + it('순수 이동(치수 불변)은 콘텐츠를 건드리지 않는다', () => { + const position = basePosition(); + const next = projectSpriteResize(position, { + dx: 300, + dy: -40, + width: 200, + height: 100, + }); + expect(next.imageRect).toEqual(position.imageRect); + expect(next.idleTransform).toEqual(position.idleTransform); + expect(next.poses[0].transform).toEqual(position.poses[0].transform); + }); + + it('배율 1은 클램프 없는 passthrough - 하한 미만 치수·범위 밖 오프셋 보존', () => { + const position = { + ...basePosition(), + imageRect: { x: 40, y: -10, width: 1e-7, height: 80 }, + idleTransform: { x: 5000, y: -6, rotation: 0, scale: 1 }, + }; + const next = projectSpriteResize(position, { + dx: 300, + dy: -40, + width: 200, + height: 100, + }); + expect(next.imageRect.width).toBe(1e-7); + expect(next.idleTransform.x).toBe(5000); + }); + + it('스케일 결과는 offset·imageRect 한계에 클램프된다', () => { + const position = { + ...basePosition(), + idleTransform: { x: 1500, y: -1500, rotation: 0, scale: 1 }, + }; + const next = projectSpriteResize(position, { + dx: 0, + dy: 0, + width: 2000, + height: 1000, + }); + // sx=sy=10: 15000 → 2000, -15000 → -2000 + expect(next.idleTransform.x).toBe(2000); + expect(next.idleTransform.y).toBe(-2000); + }); + + it('무효 prev 치수는 해당 축을 무배율로 방어한다', () => { + const position = { ...basePosition(), width: 0 }; + const next = projectSpriteResize(position, { + dx: 10, + dy: 20, + width: 300, + height: 100, + }); + expect(next.width).toBe(300); + expect(next.imageRect.x).toBe(40); + expect(next.imageRect.width).toBe(160); + }); +}); + +describe('spriteResizeRatio', () => { + it('언더플로 0은 허용하고 비유한은 1로 방어한다', () => { + expect(spriteResizeRatio(4, 5e-324)).toBe(0); + expect(spriteResizeRatio(5e-324, 32768)).toBe(1); + expect(spriteResizeRatio(Number.NaN, 100)).toBe(1); + expect(spriteResizeRatio(100, Number.POSITIVE_INFINITY)).toBe(1); + }); +}); + +describe('isSameSpriteBounds', () => { + it('네 값 전부 일치할 때만 참이다', () => { + const position = basePosition(); + expect( + isSameSpriteBounds(position, { dx: 10, dy: 20, width: 200, height: 100 }), + ).toBe(true); + expect( + isSameSpriteBounds(position, { dx: 10, dy: 20, width: 200, height: 101 }), + ).toBe(false); + }); +}); diff --git a/src/renderer/utils/sprite/resizeProjection.ts b/src/renderer/utils/sprite/resizeProjection.ts new file mode 100644 index 000000000..8e4dfe38c --- /dev/null +++ b/src/renderer/utils/sprite/resizeProjection.ts @@ -0,0 +1,121 @@ +import type { SpriteRect, SpriteTransform } from '@src/types/key/sprites'; +import { SPRITE_CONSTRAINTS } from '@src/types/key/sprites'; +import type { EditorBoundsV1 } from '@src/types/editor'; + +// 스프라이트 리사이즈 projection - resizeSprite op의 프론트 구현. +// 백엔드 적용기와 분기·연산 순서(나눗셈 1회, 값마다 곱셈 1회, clamp는 +// max→min)를 비트 단위로 공유한다. eager 적용·낙관 target·conflict rebase가 +// 전부 이 구현 하나를 소비한다 + +export interface SpriteResizeProjectable { + dx: number; + dy: number; + width: number; + height: number; + imageRect: SpriteRect; + idleTransform: SpriteTransform; + poses: ReadonlyArray<{ transform: SpriteTransform }>; +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +// 유한 비음수 배율. 무효 prev/next와 subnormal 나눗셈 오버플로는 1(무배율)로, +// 극단 축소의 언더플로 0은 축소 의도 보존을 위해 그대로 허용한다 +export const spriteResizeRatio = (prev: number, next: number): number => { + if ( + !(prev > 0) || + !Number.isFinite(prev) || + !(next > 0) || + !Number.isFinite(next) + ) { + return 1; + } + const ratio = next / prev; + return Number.isFinite(ratio) ? ratio : 1; +}; + +// 필드 종류별 클램프 범위 - transform offset, imageRect 좌표, imageRect 치수 +export type SpriteResizeValueField = 'offset' | 'coord' | 'dimension'; + +export const scaleSpriteResizeValue = ( + value: number, + ratio: number, + field: SpriteResizeValueField, +): number => { + // 배율 1은 클램프 없는 완전 passthrough - 순수 이동이 검증상 유효한 + // 극소 치수(하한 미만)까지 비트 단위로 보존한다 + if (ratio === 1) return value; + const { imageRect, offset, resizeMinDimension } = SPRITE_CONSTRAINTS; + const min = + field === 'offset' + ? offset.min + : field === 'coord' + ? imageRect.coordMin + : resizeMinDimension; + const max = + field === 'offset' + ? offset.max + : field === 'coord' + ? imageRect.coordMax + : imageRect.dimensionMax; + return clamp(value * ratio, min, max); +}; + +const scaleOffset = (value: number, ratio: number): number => + scaleSpriteResizeValue(value, ratio, 'offset'); + +const scaleTransform = ( + transform: SpriteTransform, + sx: number, + sy: number, +): SpriteTransform => ({ + ...transform, + x: scaleOffset(transform.x, sx), + y: scaleOffset(transform.y, sy), +}); + +export const isSameSpriteBounds = ( + position: Pick, + bounds: EditorBoundsV1, +): boolean => + position.dx === bounds.dx && + position.dy === bounds.dy && + position.width === bounds.width && + position.height === bounds.height; + +/** + * bounds 교체 + 이전 bounds 대비 배율로 콘텐츠(px 좌표) 스케일. + * rotation·scale·pivot·contactPoint는 불변 - 정규화 좌표는 imageRect를 + * 자동 추종한다. bounds가 동일하면 원본을 그대로 반환한다 (noChange) + */ +export const projectSpriteResize = ( + position: T, + bounds: EditorBoundsV1, +): T => { + if (isSameSpriteBounds(position, bounds)) return position; + const sx = spriteResizeRatio(position.width, bounds.width); + const sy = spriteResizeRatio(position.height, bounds.height); + return { + ...position, + dx: bounds.dx, + dy: bounds.dy, + width: bounds.width, + height: bounds.height, + imageRect: { + x: scaleSpriteResizeValue(position.imageRect.x, sx, 'coord'), + y: scaleSpriteResizeValue(position.imageRect.y, sy, 'coord'), + width: scaleSpriteResizeValue(position.imageRect.width, sx, 'dimension'), + height: scaleSpriteResizeValue( + position.imageRect.height, + sy, + 'dimension', + ), + }, + idleTransform: scaleTransform(position.idleTransform, sx, sy), + poses: position.poses.map((pose) => ({ + ...pose, + transform: scaleTransform(pose.transform, sx, sy), + })), + }; +}; diff --git a/src/types/editor.ts b/src/types/editor.ts index d182ab3ca..b7ba06984 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -53,7 +53,7 @@ export const EDITOR_SCHEMA_VERSION = 1 as const; // 유지하고 id를 additive로 싣는다. v2 커밋은 포함된 모든 위치 항목에 유효 ID가 // 필수라 백엔드가 형식·전역 유일성을 강제한다. 구형 플러그인 gateway만 v1로 남는다 export const EDITOR_COMMIT_SCHEMA_VERSION = 2 as const; -export const EDITOR_OPS_VERSION = 2 as const; +export const EDITOR_OPS_VERSION = 3 as const; export const EDITOR_FIELDS = [ 'keys', @@ -158,6 +158,16 @@ export interface EditorSetBoundsOpV1 { bounds: EditorBoundsV1; } +// 스프라이트 전용 - bounds 교체와 함께 imageRect·idleTransform·poses의 +// px 좌표를 이전 bounds 대비 배율로 스케일한다. 배율은 적용 시점 문서의 +// bounds 기준(last-writer-wins) - 드래그 중 외부 patch가 끼어들어도 +// 최종 bounds는 요청 값, 콘텐츠는 최신 상태 기준 비례로 수렴한다 +export interface EditorResizeSpriteOpV1 { + kind: 'resizeSprite'; + id: string; + bounds: EditorBoundsV1; +} + export interface EditorDeleteElementOpV1 { kind: 'deleteElement'; elementType: EditorElementTypeV1; @@ -573,6 +583,7 @@ export interface EditorSetKeySlotOpV1 { export type EditorOpV1 = | EditorSetBoundsOpV1 + | EditorResizeSpriteOpV1 | EditorDeleteElementOpV1 | EditorInsertFrozenElementsOpV1 | EditorReorderElementsOpV1 @@ -1945,6 +1956,15 @@ export function assertEditorOpsV1( assertEditorBounds(op.bounds, `${opLabel}.bounds`); return; } + if (op.kind === 'resizeSprite') { + assertExactKeys(op, ['kind', 'id', 'bounds'], opLabel); + if (!isNativeElementId(op.id)) { + throw new EditorProtocolError(`${opLabel} target is invalid`); + } + assertUniqueDirectTarget(op.id, opLabel); + assertEditorBounds(op.bounds, `${opLabel}.bounds`); + return; + } if (op.kind === 'deleteElement') { assertExactKeys(op, ['kind', 'elementType', 'id'], opLabel); if ( @@ -2330,6 +2350,22 @@ export function assertEditorOpCommitResult( } return; } + if (op.kind === 'resizeSprite') { + if ( + result.status !== 'targetMissing' && + ((result.status !== 'applied' && result.status !== 'noChange') || + result.bounds === undefined) + ) { + throw new EditorProtocolError( + `editor_commit opResults[${index}] is invalid for resizeSprite`, + ); + } + if (result.status === 'applied') { + requiredFields.add('spritePositions'); + allowedFields.add('spritePositions'); + } + return; + } if (op.kind === 'deleteElement') { if ( result.status === 'noChange' || diff --git a/tests/editor-ops-parity.test.ts b/tests/editor-ops-parity.test.ts index 7f8a7eec6..e29cf8332 100644 --- a/tests/editor-ops-parity.test.ts +++ b/tests/editor-ops-parity.test.ts @@ -45,6 +45,7 @@ const fixture = (): OpsParityFixture => // 잡으므로 신규 op 추가 시 이 목록과 fixture 케이스를 함께 늘려야 한다 const ALL_OP_KINDS = { setBounds: true, + resizeSprite: true, deleteElement: true, insertFrozenElements: true, reorderElements: true, @@ -77,7 +78,7 @@ const createParityHarness = (initial: CanonicalEditorDocumentV1) => { changedFields, opResults: (request.ops ?? []).map( (op): EditorOpResultV1 => - op.kind === 'setBounds' + op.kind === 'setBounds' || op.kind === 'resizeSprite' ? { status, bounds: op.bounds } : { status }, ), diff --git a/tests/editor-property-wire-parity.test.ts b/tests/editor-property-wire-parity.test.ts index f05772d8b..2492bb167 100644 --- a/tests/editor-property-wire-parity.test.ts +++ b/tests/editor-property-wire-parity.test.ts @@ -27,10 +27,10 @@ describe('editor property wire parity', () => { expect([...EDITOR_ELEMENT_PROPERTY_KEYS]).toEqual(properties); }); - it('ops 버전은 fixture version과 일치하는 2다', () => { + it('ops 버전은 fixture version과 일치하는 3이다', () => { // 양방향 anchor: 한쪽 상수만 승격되면 fixture 대조에서 걸린다 // (Rust는 models::editor 테스트가 같은 fixture와 대조) - expect(EDITOR_OPS_VERSION).toBe(2); + expect(EDITOR_OPS_VERSION).toBe(3); expect(fixture().version).toBe(EDITOR_OPS_VERSION); }); diff --git a/tests/fixtures/editor-ops-parity.json b/tests/fixtures/editor-ops-parity.json index 0fff65553..f565f517a 100644 --- a/tests/fixtures/editor-ops-parity.json +++ b/tests/fixtures/editor-ops-parity.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "comment": "semantic op 결과 동등성 파리티 fixture. 소비자: src-tauri/src/state/editor_ops_parity.rs (canonical 적용기), tests/editor-ops-parity.test.ts (프론트 낙관 적용기). 각 케이스는 같은 initialDocument에 같은 ops를 적용했을 때 양측이 만드는 문서가 expectedDocument와 일치함을 고정한다. 플러그인 그룹 멤버 원천(양측 근사 지점)은 양쪽 모두 비어 있는 상태를 전제한다.", "cases": [ { @@ -1697,10 +1697,7 @@ "kind": "patchElement", "elementType": "key", "id": "11111111-1111-4111-8111-111111111111", - "patch": { - "property": "noteGlowSyncPaint", - "value": true - } + "patch": { "property": "noteGlowSyncPaint", "value": true } } ], "expectedDocument": { @@ -3095,6 +3092,294 @@ "layerGroups": { "4key": [{ "id": "group-a", "name": "Renamed" }] }, "spritePositions": {} } + }, + { + "name": "resizesprite-scale-with-setbounds-batch", + "comment": "resizeSprite는 bounds 교체 + 이전 bounds 대비 배율(sx=2, sy=0.5)로 imageRect·idleTransform·poses 오프셋을 스케일한다. rotation·scale·pivot·contactPoint 불변. 같은 배치의 다른 요소 setBounds와 원자 커밋", + "initialDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 10, + "dy": 20, + "width": 200, + "height": 100, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "imageFit": null, + "imageRect": { "x": 40, "y": -10, "width": 160, "height": 80 }, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 12, + "y": -6, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -30, + "y": 44, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null, + "contactPoint": { "x": 0.5, "y": 1 } + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + }, + "ops": [ + { + "kind": "resizeSprite", + "id": "22222222-2222-4222-8222-222222222222", + "bounds": { "dx": 5, "dy": 8, "width": 400, "height": 50 } + }, + { + "kind": "setBounds", + "elementType": "key", + "id": "11111111-1111-4111-8111-111111111111", + "bounds": { "dx": 100, "dy": 0, "width": 60, "height": 60 } + } + ], + "expectedDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 100, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 5, + "dy": 8, + "width": 400, + "height": 50, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "imageFit": null, + "imageRect": { "x": 80, "y": -5, "width": 320, "height": 40 }, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 24, + "y": -3, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -60, + "y": 22, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null, + "contactPoint": { "x": 0.5, "y": 1 } + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + } + }, + { + "name": "resizesprite-pure-move-keeps-content", + "comment": "치수 불변 이동은 배율 1이라 콘텐츠를 건드리지 않는다", + "initialDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 10, + "dy": 20, + "width": 200, + "height": 100, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "imageFit": null, + "imageRect": { "x": 40, "y": -10, "width": 160, "height": 80 }, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 12, + "y": -6, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -30, + "y": 44, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null, + "contactPoint": { "x": 0.5, "y": 1 } + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + }, + "ops": [ + { + "kind": "resizeSprite", + "id": "22222222-2222-4222-8222-222222222222", + "bounds": { "dx": 300, "dy": -40, "width": 200, "height": 100 } + } + ], + "expectedDocument": { + "schemaVersion": 1, + "keys": { "4key": ["A"] }, + "keyPositions": { + "4key": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "dx": 0, + "dy": 0, + "width": 60, + "height": 60, + "count": 0, + "noteColor": "#FFFFFF", + "noteOpacity": 90 + } + ] + }, + "statPositions": {}, + "graphPositions": {}, + "knobPositions": {}, + "layerGroups": {}, + "spritePositions": { + "4key": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "dx": 300, + "dy": -40, + "width": 200, + "height": 100, + "hidden": false, + "zIndex": null, + "className": null, + "useInlineStyles": null, + "baseImage": null, + "imageFit": null, + "imageRect": { "x": 40, "y": -10, "width": 160, "height": 80 }, + "pivot": { "x": 0.5, "y": 0.5 }, + "idleTransform": { + "x": 12, + "y": -6, + "rotation": 15, + "scale": 1.5 + }, + "poses": [ + { + "poseId": "44444444-4444-4444-8444-444444444444", + "triggers": ["11111111-1111-4111-8111-111111111111"], + "transform": { + "x": -30, + "y": 44, + "rotation": -90, + "scale": 0.5 + }, + "imageOverride": null, + "contactPoint": { "x": 0.5, "y": 1 } + } + ], + "activation": "whileHeld", + "pressDurationMs": 300, + "transitionMs": 90, + "transitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)" + } + ] + } + } } ] } diff --git a/tests/fixtures/editor-property-tags.json b/tests/fixtures/editor-property-tags.json index 3ab5834fa..13a4b3e9c 100644 --- a/tests/fixtures/editor-property-tags.json +++ b/tests/fixtures/editor-property-tags.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 3, "properties": [ "hidden", "layerName", diff --git a/tests/fixtures/sprite-resize-scale-parity.json b/tests/fixtures/sprite-resize-scale-parity.json new file mode 100644 index 000000000..43dfad453 --- /dev/null +++ b/tests/fixtures/sprite-resize-scale-parity.json @@ -0,0 +1,161 @@ +{ + "version": 1, + "comment": "resizeSprite 스케일 수학 파리티 fixture. ratio = next/prev (무효·비유한은 1, 언더플로 0 허용), 값 적용 = clamp(value * ratio, min, max), clamp는 max(min)→min(max) 순서. 필드 범위: offset ±2000, coord ±32768, dimension [0.000001, 32768]. 소비자: tests/sprite-resize-scale-parity.test.ts, src-tauri Rust 테스트. expectedHex는 IEEE754 f64 big-endian 비트 - 양측이 to_bits로 대조한다", + "ranges": { + "offset": [-2000, 2000], + "coord": [-32768, 32768], + "dimension": [0.000001, 32768] + }, + "cases": [ + { + "name": "offset-uniform-double", + "field": "offset", + "prev": 100, + "next": 200, + "value": 250, + "expected": 500, + "expectedHex": "0x407f400000000000" + }, + { + "name": "offset-nonuniform-shrink", + "field": "offset", + "prev": 320, + "next": 200, + "value": -40.5, + "expected": -25.3125, + "expectedHex": "0xc039500000000000" + }, + { + "name": "offset-clamp-max", + "field": "offset", + "prev": 100, + "next": 1000, + "value": 250, + "expected": 2000, + "expectedHex": "0x409f400000000000" + }, + { + "name": "offset-clamp-min", + "field": "offset", + "prev": 100, + "next": 1000, + "value": -250, + "expected": -2000, + "expectedHex": "0xc09f400000000000" + }, + { + "name": "offset-negative-zero", + "field": "offset", + "prev": 100, + "next": 200, + "value": -0, + "expected": -0, + "expectedHex": "0x8000000000000000" + }, + { + "name": "offset-third-bits", + "field": "offset", + "prev": 3, + "next": 1, + "value": 99, + "expected": 33, + "expectedHex": "0x4040800000000000" + }, + { + "name": "offset-ratio-underflow-zero", + "field": "offset", + "prev": 4, + "next": 5e-324, + "value": 123, + "expected": 0, + "expectedHex": "0x0000000000000000" + }, + { + "name": "coord-nonuniform", + "field": "coord", + "prev": 320, + "next": 200, + "value": -40.5, + "expected": -25.3125, + "expectedHex": "0xc039500000000000" + }, + { + "name": "coord-clamp-min", + "field": "coord", + "prev": 10, + "next": 32768, + "value": -1000, + "expected": -32768, + "expectedHex": "0xc0e0000000000000" + }, + { + "name": "dimension-basic", + "field": "dimension", + "prev": 200, + "next": 150, + "value": 180, + "expected": 135, + "expectedHex": "0x4060e00000000000" + }, + { + "name": "dimension-floor-underflow", + "field": "dimension", + "prev": 1000, + "next": 1e-9, + "value": 5, + "expected": 0.000001, + "expectedHex": "0x3eb0c6f7a0b5ed8d" + }, + { + "name": "dimension-clamp-max", + "field": "dimension", + "prev": 10, + "next": 32768, + "value": 100, + "expected": 32768, + "expectedHex": "0x40e0000000000000" + }, + { + "name": "dimension-ratio-one-passthrough-below-floor", + "field": "dimension", + "prev": 200, + "next": 200, + "value": 1e-7, + "expected": 1e-7, + "expectedHex": "0x3e7ad7f29abcaf48" + }, + { + "name": "offset-ratio-one-passthrough-beyond-max", + "field": "offset", + "prev": 100, + "next": 100, + "value": 5000, + "expected": 5000, + "expectedHex": "0x40b3880000000000" + }, + { + "name": "ratio-subnormal-overflow-guard", + "field": "ratio", + "prev": 5e-324, + "next": 32768, + "expected": 1, + "expectedHex": "0x3ff0000000000000" + }, + { + "name": "ratio-underflow-zero", + "field": "ratio", + "prev": 4, + "next": 5e-324, + "expected": 0, + "expectedHex": "0x0000000000000000" + }, + { + "name": "ratio-identity-same", + "field": "ratio", + "prev": 240, + "next": 240, + "expected": 1, + "expectedHex": "0x3ff0000000000000" + } + ] +} diff --git a/tests/sprite-resize-scale-parity.test.ts b/tests/sprite-resize-scale-parity.test.ts new file mode 100644 index 000000000..5a2058698 --- /dev/null +++ b/tests/sprite-resize-scale-parity.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { SPRITE_CONSTRAINTS } from '../src/types/key/sprites'; +import { + scaleSpriteResizeValue, + spriteResizeRatio, + type SpriteResizeValueField, +} from '../src/renderer/utils/sprite/resizeProjection'; + +// Rust 적용기와 같은 fixture를 공유해 resizeSprite 스케일 수학의 f64 결과를 +// 비트 단위로 고정한다. 비교는 expectedHex(IEEE754 big-endian)로만 한다 - +// decimal expected는 사람 읽기용이고, -0·subnormal은 decimal 비교가 놓친다 +const FIXTURE_PATH = join( + __dirname, + 'fixtures', + 'sprite-resize-scale-parity.json', +); + +interface ResizeScaleCase { + name: string; + field: SpriteResizeValueField | 'ratio'; + prev: number; + next: number; + value?: number; + expected: number; + expectedHex: string; +} + +interface ResizeScaleFixture { + version: number; + ranges: Record; + cases: ResizeScaleCase[]; +} + +const fixture = JSON.parse( + readFileSync(FIXTURE_PATH, 'utf8'), +) as ResizeScaleFixture; + +const bitsHex = (value: number): string => { + const buffer = new ArrayBuffer(8); + new DataView(buffer).setFloat64(0, value, false); + return ( + '0x' + + [...new Uint8Array(buffer)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + ); +}; + +describe('sprite resize scale parity (fixture)', () => { + it('fixture 범위는 SPRITE_CONSTRAINTS와 일치한다', () => { + expect(fixture.ranges.offset).toEqual([ + SPRITE_CONSTRAINTS.offset.min, + SPRITE_CONSTRAINTS.offset.max, + ]); + expect(fixture.ranges.coord).toEqual([ + SPRITE_CONSTRAINTS.imageRect.coordMin, + SPRITE_CONSTRAINTS.imageRect.coordMax, + ]); + expect(fixture.ranges.dimension).toEqual([ + SPRITE_CONSTRAINTS.resizeMinDimension, + SPRITE_CONSTRAINTS.imageRect.dimensionMax, + ]); + }); + + it('케이스는 비어 있지 않고 필드 종류를 전수 커버한다', () => { + const fields = new Set(fixture.cases.map((entry) => entry.field)); + expect([...fields].sort()).toEqual( + ['coord', 'dimension', 'offset', 'ratio'].sort(), + ); + }); + + it.each(fixture.cases.map((entry) => [entry.name, entry] as const))( + '%s', + (_name, entry) => { + const ratio = spriteResizeRatio(entry.prev, entry.next); + const actual = + entry.field === 'ratio' + ? ratio + : scaleSpriteResizeValue(entry.value ?? 0, ratio, entry.field); + expect(bitsHex(actual)).toBe(entry.expectedHex); + }, + ); +}); From 6ee83e0d7ec942c43c79133fa61fa3a283b23440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:09:00 +0900 Subject: [PATCH 029/125] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=9D=90=EB=A6=84=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=ED=99=94=EC=99=80=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=EB=9F=AC=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EC=A0=9C=EC=99=B8=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.tsx | 60 +++++++------------ .../single/spriteCompilerGuard.test.ts | 41 +++++++++++++ .../main/Grid/handles/SpritePoseGizmo.tsx | 3 + .../Modal/content/pickers/ImagePicker.tsx | 60 +++++++------------ src/renderer/utils/core/pickValidatedImage.ts | 46 ++++++++++++++ 5 files changed, 131 insertions(+), 79 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts create mode 100644 src/renderer/utils/core/pickValidatedImage.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index e45ee43bf..9b1295930 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -6,8 +6,7 @@ import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useSpriteStore } from '@stores/data/useSpriteStore'; import { spriteItemsApi } from '@api/modules/itemsApi'; -import { imageApi } from '@api/modules/resourceApi'; -import { canDecodeImage } from '@utils/core/assetProbe'; +import { pickValidatedImagePath } from '@utils/core/pickValidatedImage'; import { isHTMLElementNode } from '@utils/dom/isElementNode'; import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; @@ -184,7 +183,12 @@ export const SingleSpritePanel: React.FC = ({ const canonicalPosition = storePosition ?? position; const [lastPositionSnapshot, setLastPositionSnapshot] = useState(canonicalPosition); - const [resizeGestureCancelTick, setResizeGestureCancelTick] = useState(0); + // 리사이즈 착지 취소 신호 - 착지 시점의 스프라이트를 함께 실어 effect가 + // 렌더 시점 대상이 아니라 착지 대상에 작용한다 + const [resizeGestureCancel, setResizeGestureCancel] = useState<{ + tick: number; + positionId: string; + } | null>(null); if (lastPositionSnapshot !== canonicalPosition) { setLastPositionSnapshot(canonicalPosition); if ( @@ -220,7 +224,10 @@ export const SingleSpritePanel: React.FC = ({ // 팝업 리마운트가 진행 중 스크럽 세션을 취소로 닫고, 아래 effect가 // preview 게스처를 정산해 이전 배율의 절대값 커밋을 차단한다 if (editorTarget && editorTarget.positionId === canonicalPosition.id) { - setResizeGestureCancelTick((tick) => tick + 1); + setResizeGestureCancel((prev) => ({ + tick: (prev?.tick ?? 0) + 1, + positionId: canonicalPosition.id, + })); } } } @@ -230,12 +237,12 @@ export const SingleSpritePanel: React.FC = ({ // 정산이 이미 끝난 일반 경로에는 영향이 없다 (계약의 세션 정산 폴백). // 기즈모는 세대 무효화로 진행 중 드래그의 pointerup 커밋을 떨군다 useLayoutEffect(() => { - if (resizeGestureCancelTick === 0) return; + if (!resizeGestureCancel) return; editGestureController.cancel(); - useSpritePoseGizmoStore.getState().invalidateOwnership(position.id); - // 착지 시점의 스프라이트가 대상 - id는 세션 수명 동안 불변 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [resizeGestureCancelTick]); + useSpritePoseGizmoStore + .getState() + .invalidateOwnership(resizeGestureCancel.positionId); + }, [resizeGestureCancel]); // 담당 키 후보: 현재 모드의 키 요소 목록 (값은 요소 id, 라벨은 슬롯 표시명) const modeSlots = keyMappings[selectedKeyType] ?? []; @@ -775,37 +782,12 @@ export const SingleSpritePanel: React.FC = ({ } }; - const showInvalidImageAlert = (): void => { - void window.api.ui.dialog - .alert(t('imagePicker.invalidImage'), { - confirmText: t('common.ok') || '확인', - }) - .catch((error) => { - console.error('Failed to open invalid image alert:', error); - }); - }; - - // 키 패널과 동일한 이미지 선택 흐름 (image_load + 디코드 확인) - // finalizer 없는 형태 유지 - try/finally는 React Compiler가 컴포넌트 전체를 - // 최적화에서 제외한다. 재진입 플래그는 모든 경로가 지나는 아래 한 곳에서 푼다 + // 이미지 피커와 같은 선택 흐름 (image_load + 디코드 확인). + // 재진입 플래그만 여기서 관리한다 const pickImage = async (): Promise => { if (loadingImageRef.current) return null; loadingImageRef.current = true; - let picked: string | null = null; - try { - const result = await imageApi.load(); - if (!result?.success || !result.imagePath) { - // errorCode가 없는 실패는 사용자 취소 - if (result?.errorCode) showInvalidImageAlert(); - } else if (!(await canDecodeImage(result.imagePath))) { - // 시그니처를 통과해도 WebView가 못 그리는 파일이 있다. 직전 값을 덮기 전에 확인한다 - showInvalidImageAlert(); - } else { - picked = result.imagePath; - } - } catch (error) { - console.error('Failed to load image', error); - } + const picked = await pickValidatedImagePath(t); loadingImageRef.current = false; return picked; }; @@ -1261,7 +1243,7 @@ export const SingleSpritePanel: React.FC = ({ = ({ ) : editingPose ? ( { + const filename = resolve(process.cwd(), relativePath); + const result = transformSync(readFileSync(filename, 'utf8'), { + filename, + configFile: false, + babelrc: false, + parserOpts: { plugins: ['jsx', 'typescript'] }, + plugins: [['babel-plugin-react-compiler', {}]], + }); + return result?.code ?? ''; +}; + +const COMPILED = [ + 'src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx', + 'src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx', +]; + +describe('스프라이트 편집 컴파일러 가드', () => { + it.each(COMPILED)('%s는 컴파일 대상으로 남는다', (path) => { + expect(compile(path)).toContain('react/compiler-runtime'); + }); + + it("SpritePoseGizmo는 'use no memo'로 제외를 명시한다", () => { + const path = + 'src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx'; + expect(readFileSync(resolve(process.cwd(), path), 'utf8')).toMatch( + /^'use no memo';/, + ); + expect(compile(path)).not.toContain('react/compiler-runtime'); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx index 2cfec2dcc..16f59345a 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -1,3 +1,6 @@ +'use no memo'; +// 렌더 중 ref 대입(세션·지오메트리 최신화)이 React Compiler bailout이라 +// 컴파일 대상에서 빠진다. 조용한 제외와 구분되게 명시한다 (GradientAxisHandle과 같은 이유) import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { useSpritePoseGizmoStore } from '@stores/grid/useSpritePoseGizmoStore'; diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx index 8cf855a52..5e2bcea76 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import PickerSurface from '@components/main/Grid/PropertiesPanel/PickerSurface'; import Checkbox from '@components/main/common/Checkbox'; @@ -11,11 +11,10 @@ import { } from '@components/main/common/TransformGlyphs'; import { PropertySection } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; import { resolveImageSource } from '@utils/core/imageSource'; -import { canDecodeImage } from '@utils/core/assetProbe'; +import { pickValidatedImagePath } from '@utils/core/pickValidatedImage'; import { useEditSessionCompletionGuard } from '@src/renderer/contexts/EditSessionScope'; import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; -import { imageApi } from '@api/modules/resourceApi'; import { DEFAULT_IMAGE_MODE, IDENTITY_IMAGE_TRANSFORM, @@ -123,49 +122,30 @@ const ImagePicker = ({ // 열려 있는 동안 편집 상태를 캔버스 프리뷰로 발행 useEditStatePreviewPublisher(open ? previewAnchor : null, effectiveMode); - useEffect(() => { + // 입력 상태 탭이 사라지면 저장된 모드도 idle로 되돌린다. + // effect의 동기 setState는 캐스케이드 렌더라 렌더 중 보정 패턴을 쓴다 + const [lastShowActiveState, setLastShowActiveState] = + useState(showActiveState); + if (lastShowActiveState !== showActiveState) { + setLastShowActiveState(showActiveState); if (!showActiveState) setMode(STATE_MODES.idle); - }, [showActiveState]); - - const showInvalidImageAlert = (): void => { - void window.api.ui.dialog - .alert(t('imagePicker.invalidImage'), { - confirmText: t('common.ok') || '확인', - }) - .catch((error) => { - console.error('Failed to open invalid image alert:', error); - }); - }; + } const handleImageClick = async (stateMode: string): Promise => { if (loadingImageRef.current) return; loadingImageRef.current = true; setIsLoadingImage(true); - try { - const result = await imageApi.load(); - if (!result?.success || !result.imagePath) { - // errorCode가 없는 실패는 사용자 취소 - if (result?.errorCode) showInvalidImageAlert(); - return; - } - // 시그니처를 통과해도 WebView가 못 그리는 파일이 있다. 직전 값을 덮기 전에 확인한다 - if (!(await canDecodeImage(result.imagePath))) { - showInvalidImageAlert(); - return; - } - // 파일 복사는 이미 끝났다. 대상이 갈렸으면 연결만 하지 않는다 - // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) - if (!canBindCompletion()) return; - if (stateMode === STATE_MODES.idle) { - onIdleImageChange?.(result.imagePath); - } else { - onActiveImageChange?.(result.imagePath); - } - } catch (error) { - console.error('Failed to load image', error); - } finally { - loadingImageRef.current = false; - setIsLoadingImage(false); + const picked = await pickValidatedImagePath(t); + loadingImageRef.current = false; + setIsLoadingImage(false); + if (!picked) return; + // 파일 복사는 이미 끝났다. 대상이 갈렸으면 연결만 하지 않는다 + // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) + if (!canBindCompletion()) return; + if (stateMode === STATE_MODES.idle) { + onIdleImageChange?.(picked); + } else { + onActiveImageChange?.(picked); } }; diff --git a/src/renderer/utils/core/pickValidatedImage.ts b/src/renderer/utils/core/pickValidatedImage.ts new file mode 100644 index 000000000..03e50829b --- /dev/null +++ b/src/renderer/utils/core/pickValidatedImage.ts @@ -0,0 +1,46 @@ +import { imageApi } from '@api/modules/resourceApi'; + +import { canDecodeImage } from './assetProbe'; + +type Translate = ( + key: string, + params?: Record, +) => string; + +const showInvalidImageAlert = (t: Translate): void => { + void window.api.ui.dialog + .alert(t('imagePicker.invalidImage'), { + confirmText: t('common.ok') || '확인', + }) + .catch((error) => { + console.error('Failed to open invalid image alert:', error); + }); +}; + +/** + * 파일 선택 + 디코드 확인을 통과한 이미지 경로. 취소·실패는 null이다. + * 호출부가 try/catch를 들지 않게 여기서 전부 삼킨다 - 컴포넌트 안의 try는 + * React Compiler가 그 컴포넌트를 통째로 최적화에서 제외하는 원인이 된다. + * 재진입 가드와 로딩 표시는 호출부 몫 + */ +export const pickValidatedImagePath = async ( + t: Translate, +): Promise => { + try { + const result = await imageApi.load(); + if (!result?.success || !result.imagePath) { + // errorCode가 없는 실패는 사용자 취소 + if (result?.errorCode) showInvalidImageAlert(t); + return null; + } + // 시그니처를 통과해도 WebView가 못 그리는 파일이 있다. 직전 값을 덮기 전에 확인한다 + if (!(await canDecodeImage(result.imagePath))) { + showInvalidImageAlert(t); + return null; + } + return result.imagePath; + } catch (error) { + console.error('Failed to load image', error); + return null; + } +}; From 9fb4a631cde170e345295b8a45ac7eabdd93200a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:14:31 +0900 Subject: [PATCH 030/125] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=ED=95=98=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=AF=B8=ED=8B=B0=EB=B8=8C=EC=99=80=20=ED=81=B4=EB=9E=A8?= =?UTF-8?q?=ED=94=84=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.tsx | 18 +++++----- .../single/SpritePoseEditorPopup.tsx | 17 +++++---- .../main/Grid/handles/GradientAxisHandle.tsx | 13 +------ .../main/Grid/handles/SpritePoseGizmo.tsx | 35 +++++++------------ .../main/Grid/layers/SpriteItem.tsx | 12 +++---- .../overlay/counters/OverlaySpriteItem.tsx | 26 ++++++-------- src/renderer/hooks/useSwitchDrag.ts | 4 +-- src/renderer/utils/core/clamp.ts | 4 +++ src/renderer/utils/dom/suppressNextClick.ts | 12 +++++++ src/renderer/utils/sprite/contactSolver.ts | 17 ++------- src/renderer/utils/sprite/poseResolver.ts | 11 ++++-- src/renderer/utils/sprite/resizeProjection.ts | 5 ++- .../utils/sprite/spriteGeometry.test.ts | 29 +++++++++++++++ src/renderer/utils/sprite/spriteGeometry.ts | 29 +++++++++++++++ .../utils/sprite/spriteImageStyles.ts | 6 +++- src/renderer/utils/sprite/spriteReach.ts | 5 +-- src/types/key/sprites.ts | 3 -- 17 files changed, 139 insertions(+), 107 deletions(-) create mode 100644 src/renderer/utils/core/clamp.ts create mode 100644 src/renderer/utils/dom/suppressNextClick.ts create mode 100644 src/renderer/utils/sprite/spriteGeometry.test.ts create mode 100644 src/renderer/utils/sprite/spriteGeometry.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index 9b1295930..40eb7e4dd 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -6,7 +6,9 @@ import { isNativeElementId } from '@src/renderer/editor/model/elementId'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useSpriteStore } from '@stores/data/useSpriteStore'; import { spriteItemsApi } from '@api/modules/itemsApi'; +import { clamp } from '@utils/core/clamp'; import { pickValidatedImagePath } from '@utils/core/pickValidatedImage'; +import { anchorToPercent, percentToAnchor } from '@utils/sprite/spriteGeometry'; import { isHTMLElementNode } from '@utils/dom/isElementNode'; import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; @@ -57,9 +59,6 @@ import { RenameIcon } from '../PanelIcons'; import SpritePoseEditorPopup from './SpritePoseEditorPopup'; import SpriteImageSettingsPopup from './SpriteImageSettingsPopup'; -const clamp = (value: number, min: number, max: number): number => - Math.min(max, Math.max(min, value)); - // 계약과 동일한 cubic-bezier 문자열만 저장 (transitionEasing은 문자열 그대로 CSS로 간다) const SPRITE_EASING_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ { label: 'Default', value: DEFAULT_SPRITE_TRANSITION_EASING }, @@ -818,7 +817,6 @@ export const SingleSpritePanel: React.FC = ({ const spriteTitle = position.layerName || 'Sprite'; const { anchor, transitionMs, pressDurationMs } = SPRITE_CONSTRAINTS; - const pivotPercent = (value: number) => Math.round(value * 1000) / 10; const easingValue = position.transitionEasing || DEFAULT_SPRITE_TRANSITION_EASING; const easingOptions = SPRITE_EASING_PRESETS.some( @@ -930,12 +928,12 @@ export const SingleSpritePanel: React.FC = ({ onPointerDownCapture={() => setPivotEngaged(true)} > commitFields({ pivot: { ...position.pivot, - x: clamp(value, 0, 100) / 100, + x: percentToAnchor(value), }, }) } @@ -943,7 +941,7 @@ export const SingleSpritePanel: React.FC = ({ previewFields({ pivot: { ...position.pivot, - x: clamp(value, 0, 100) / 100, + x: percentToAnchor(value), }, }) } @@ -960,12 +958,12 @@ export const SingleSpritePanel: React.FC = ({ decimalScale={1} /> commitFields({ pivot: { ...position.pivot, - y: clamp(value, 0, 100) / 100, + y: percentToAnchor(value), }, }) } @@ -973,7 +971,7 @@ export const SingleSpritePanel: React.FC = ({ previewFields({ pivot: { ...position.pivot, - y: clamp(value, 0, 100) / 100, + y: percentToAnchor(value), }, }) } diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx index def44126b..23b564734 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -13,11 +13,10 @@ import { type SpriteImageFit, type SpriteTransform, } from '@src/types/key/sprites'; +import { clamp } from '@utils/core/clamp'; +import { anchorToPercent, percentToAnchor } from '@utils/sprite/spriteGeometry'; import SpriteImagePreviewCard from './SpriteImagePreviewCard'; -const clamp = (value: number, min: number, max: number): number => - Math.min(max, Math.max(min, value)); - // 담당 키·이미지 오버라이드 컨트롤 묶음 (삭제·이름 변경은 행 메뉴가 맡는다) export interface SpritePoseControls { keyOptions: ReadonlyArray<{ id: string; label: string }>; @@ -225,17 +224,17 @@ const SpritePoseEditorPopup: React.FC = ({
pinControls.onContactPointCommit({ ...pinControls.contactPoint, - x: clamp(value, 0, 100) / 100, + x: percentToAnchor(value), }) } onPreview={(value) => pinControls.onContactPointPreview({ ...pinControls.contactPoint, - x: clamp(value, 0, 100) / 100, + x: percentToAnchor(value), }) } onCancel={onTransformCancel} @@ -251,17 +250,17 @@ const SpritePoseEditorPopup: React.FC = ({ decimalScale={1} /> pinControls.onContactPointCommit({ ...pinControls.contactPoint, - y: clamp(value, 0, 100) / 100, + y: percentToAnchor(value), }) } onPreview={(value) => pinControls.onContactPointPreview({ ...pinControls.contactPoint, - y: clamp(value, 0, 100) / 100, + y: percentToAnchor(value), }) } onCancel={onTransformCancel} diff --git a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx index 7a5853fd5..05cb11f0d 100644 --- a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx +++ b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx @@ -20,6 +20,7 @@ import { } from '@utils/animation/rafLatestScheduler'; import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { suppressNextClick } from '@utils/dom/suppressNextClick'; /** * 온캔버스 그라데이션 축 - 피커가 그라데이션 형식으로 열려 있는 동안 @@ -584,18 +585,6 @@ const GradientAxisOverlay = ({ // 창 포커스 상실 - pointerup이 오지 않으므로 유령 드래그 방지 취소 const handleWindowBlur = () => cancelActiveDrag(); - // 드래그 제스처의 후속 click이 그리드 선택 해제로 새지 않게 1회 억제 - const suppressNextClick = () => { - const swallow = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - window.removeEventListener('click', swallow, true); - }; - window.addEventListener('click', swallow, true); - // click이 아예 안 오는 경로(cancel 등) 대비 - 다음 틱에 정리 - setTimeout(() => window.removeEventListener('click', swallow, true), 0); - }; - // 프레스 즉시 grabbing - 키와 같은 정책(호버 무변화, 잡는 동안만 grabbing). // pointerdown preventDefault로 :active가 안 걸리고, 캡처 중에도 커서는 // 히트테스트 기준(크로뮴)이라 문서 전역 클래스로 드래그 내내 고정한다 diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx index 16f59345a..c6a09d3a3 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -5,16 +5,18 @@ import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from '@contexts/useTranslation'; import { useSpritePoseGizmoStore } from '@stores/grid/useSpritePoseGizmoStore'; import { - CONTACT_EPSILON, contactWorldPosition, solveTransformTowardTarget, type ContactGeometry, } from '@utils/sprite/contactSolver'; import { + DEFAULT_SPRITE_CONTACT_POINT, SPRITE_CONSTRAINTS, type SpriteAnchor, type SpriteTransform, } from '@src/types/key/sprites'; +import { anchorPx } from '@utils/sprite/spriteGeometry'; +import { suppressNextClick } from '@utils/dom/suppressNextClick'; import { createRafLatestScheduler } from '@utils/animation/rafLatestScheduler'; import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; import { @@ -47,6 +49,9 @@ interface DragState { } const KNOB_HIT_SIZE = 22; +// 노브를 잡을 수 있는 최소 팔 길이(요소 로컬 px). 솔버의 CONTACT_EPSILON은 +// "수학적으로 방향이 정의되지 않음"이고 이쪽은 "화면에서 집을 수 없음"이라 따로 둔다 +const MIN_GRABBABLE_ARM_PX = 1e-3; const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { @@ -118,18 +123,15 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { }; const displayTransform = dragTransform ?? session.transform; - const axisPx = { - x: session.imageRect.x + session.pivot.x * session.imageRect.width, - y: session.imageRect.y + session.pivot.y * session.imageRect.height, - }; + const axis = anchorPx(session.imageRect, session.pivot); const contactWorld = contactWorldPosition(geometry, displayTransform); const knobScreen = { x: (session.origin.dx + contactWorld.x) * zoom + panX, y: (session.origin.dy + contactWorld.y) * zoom + panY, }; const axisScreen = { - x: (session.origin.dx + displayTransform.x + axisPx.x) * zoom + panX, - y: (session.origin.dy + displayTransform.y + axisPx.y) * zoom + panY, + x: (session.origin.dx + displayTransform.x + axis.x) * zoom + panX, + y: (session.origin.dy + displayTransform.y + axis.y) * zoom + panY, }; // 핀=축 퇴화 - 방향이 정의되지 않아 조작 불가, 핀 재배치(Alt)만 허용 @@ -138,7 +140,7 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { y: (geometry.contactPoint.y - session.pivot.y) * session.imageRect.height, }; const degenerate = - Math.hypot(baseVector.x, baseVector.y) <= CONTACT_EPSILON * 1000; + Math.hypot(baseVector.x, baseVector.y) <= MIN_GRABBABLE_ARM_PX; const clientToElementLocal = (clientX: number, clientY: number) => { const geo = geoRef.current; @@ -154,12 +156,9 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { const targetToPin = (target: { x: number; y: number }): SpriteAnchor => { const geo = geoRef.current; const live = sessionRef.current; - if (!geo || !live) return { x: 0.5, y: 1 }; + if (!geo || !live) return DEFAULT_SPRITE_CONTACT_POINT; const { imageRect, pivot } = geo.geometry; - const axis = { - x: imageRect.x + pivot.x * imageRect.width, - y: imageRect.y + pivot.y * imageRect.height, - }; + const axis = anchorPx(imageRect, pivot); const transform = lastSolvedRef.current ?? live.transform; const rad = (-transform.rotation * Math.PI) / 180; const cos = Math.cos(rad); @@ -203,16 +202,6 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { live.preview(solved.transform); }; - const suppressNextClick = () => { - const swallow = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - window.removeEventListener('click', swallow, true); - }; - window.addEventListener('click', swallow, true); - setTimeout(() => window.removeEventListener('click', swallow, true), 0); - }; - const releaseGrabbed = () => { const grabbed = grabbedRef.current; if (!grabbed) return; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index 35385352f..c5b445779 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -7,6 +7,7 @@ import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { useSpriteEditPreview } from '@stores/grid/useSpriteEditPreviewStore'; import { resolveImageSource } from '@utils/core/imageSource'; import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; +import { anchorPx } from '@utils/sprite/spriteGeometry'; import { activityAreaGuideMetrics, editorChromeScale, @@ -120,6 +121,7 @@ const SpriteItem = ({ posePreview.fallbackPose : null; const showPivotMarker = editPreview?.kind === 'pivot'; + const pivotMarkerPx = anchorPx(position.imageRect, position.pivot); const imageSrc = resolveImageSource( previewPose?.imageOverride ?? position.baseImage, @@ -352,14 +354,8 @@ const SpriteItem = ({ data-sprite-pivot-marker="true" style={{ position: 'absolute', - left: `${ - position.imageRect.x + - position.pivot.x * position.imageRect.width - }px`, - top: `${ - position.imageRect.y + - position.pivot.y * position.imageRect.height - }px`, + left: `${pivotMarkerPx.x}px`, + top: `${pivotMarkerPx.y}px`, // 마커도 에디터 크롬이라 화면 크기 고정 - 중심은 축 위치에 남는다 transform: `translate(-50%, -50%) scale(${editorChromeScale( zoom, diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 39362f38a..624e866e0 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -11,10 +11,15 @@ import { } from '@hooks/overlay/useFailedImageSrcs'; import { resolveImageSource } from '@utils/core/imageSource'; import { warmupImageSource } from '@utils/core/imageWarmup'; -import { resolveSpriteTarget } from '@utils/sprite/poseResolver'; -import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; +import { + resolveSpriteTarget, + spriteTriggerIds, +} from '@utils/sprite/poseResolver'; +import { + computeSpriteImageStyle, + spriteTransformToCss, +} from '@utils/sprite/spriteImageStyles'; import { resolveSpriteRenderEasing } from '@utils/sprite/spriteReach'; -import { spriteTransformToCss } from '@src/types/key/sprites'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; interface OverlaySpriteItemProps { @@ -43,19 +48,8 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ const isOneShot = position.activation === 'onPress'; - // 자세들이 참조하는 키 요소 id 전체 (중복 제거) - const triggerIds = useMemo(() => { - const seen = new Set(); - const ids: string[] = []; - for (const pose of position.poses) { - for (const trigger of pose.triggers) { - if (seen.has(trigger)) continue; - seen.add(trigger); - ids.push(trigger); - } - } - return ids; - }, [position.poses]); + // 자세들이 참조하는 키 요소 id 전체. 해석기가 poses identity로 캐시해 둔 것을 그대로 쓴다 + const triggerIds = spriteTriggerIds(position.poses); // whileHeld만 렌더 중 시그널을 읽어 구독을 형성한다 - onPress는 edge 채널 전용이라 // 눌림 레벨 변화에 리렌더될 이유가 없다 diff --git a/src/renderer/hooks/useSwitchDrag.ts b/src/renderer/hooks/useSwitchDrag.ts index 626f87efe..52177efa1 100644 --- a/src/renderer/hooks/useSwitchDrag.ts +++ b/src/renderer/hooks/useSwitchDrag.ts @@ -9,6 +9,7 @@ import { import type React from 'react'; import { createRafLatestScheduler } from '@utils/animation/rafLatestScheduler'; import { prefersReducedMotion } from '@utils/animation/motionPreferences'; +import { clamp } from '@utils/core/clamp'; // 드래그 중 이동 전환을 끄는 표식. dmn- 접두사는 플러그인 마크업과의 속성 충돌 방지 const DRAG_ATTR = 'data-dmn-dragging'; @@ -67,9 +68,6 @@ interface UseSwitchDragOptions { markPress: () => void; } -const clamp = (value: number, min: number, max: number) => - Math.min(max, Math.max(min, value)); - // 슬롭은 의도를 가르는 문턱일 뿐 이동량이 아니다. 문턱을 넘은 만큼만 노브에 실어야 // 승격 순간에 튀지 않고 그 뒤로도 손끝과 1:1로 붙는다 const offsetFor = (session: DragSession, dx: number) => diff --git a/src/renderer/utils/core/clamp.ts b/src/renderer/utils/core/clamp.ts new file mode 100644 index 000000000..503a7fa0f --- /dev/null +++ b/src/renderer/utils/core/clamp.ts @@ -0,0 +1,4 @@ +// 범위 고정. max를 먼저 적용해 역전 범위(min > max)에서는 min이 이긴다 - +// 백엔드 클램프도 같은 순서라 스프라이트 수치 미러가 비트 단위로 일치한다 +export const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); diff --git a/src/renderer/utils/dom/suppressNextClick.ts b/src/renderer/utils/dom/suppressNextClick.ts new file mode 100644 index 000000000..7d644e61b --- /dev/null +++ b/src/renderer/utils/dom/suppressNextClick.ts @@ -0,0 +1,12 @@ +// 드래그 제스처 뒤에 따라오는 click 1회를 삼킨다. 캔버스 핸들의 pointerup 이후 +// click이 그리드 선택 해제로 새는 것을 막는 용도. +// click이 아예 오지 않는 경로(cancel 등) 대비로 다음 틱에 스스로 정리한다 +export const suppressNextClick = (): void => { + const swallow = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + window.removeEventListener('click', swallow, true); + }; + window.addEventListener('click', swallow, true); + setTimeout(() => window.removeEventListener('click', swallow, true), 0); +}; diff --git a/src/renderer/utils/sprite/contactSolver.ts b/src/renderer/utils/sprite/contactSolver.ts index 8b30ae004..589468494 100644 --- a/src/renderer/utils/sprite/contactSolver.ts +++ b/src/renderer/utils/sprite/contactSolver.ts @@ -5,6 +5,9 @@ import type { } from '@src/types/key/sprites'; import { SPRITE_CONSTRAINTS } from '@src/types/key/sprites'; +import { clamp } from '@utils/core/clamp'; +import { DEG_TO_RAD, RAD_TO_DEG, anchorPx } from './spriteGeometry'; + // 핀(손끝) 기반 자세 역산 - 에디터 전용 순수 기하. // 좌표계는 요소 로컬 px. transform은 translate → rotate → scale 순서이고 // pivot(transform-origin)이 회전·배율의 축이므로 @@ -26,26 +29,12 @@ export type ContactSolveResult = // 핀·축 거리, 목표 벡터가 이보다 짧으면 방향이 정의되지 않은 것으로 본다 export const CONTACT_EPSILON = 1e-6; -const DEG_TO_RAD = Math.PI / 180; -const RAD_TO_DEG = 180 / Math.PI; - -const clamp = (value: number, min: number, max: number): number => - Math.min(max, Math.max(min, value)); - // atan2 차(-360~360도)를 최단 호 표현(-180~180]으로 접는다 const wrapDegrees = (deg: number): number => { const wrapped = ((deg + 540) % 360) - 180; return wrapped === -180 ? 180 : wrapped; }; -const anchorPx = ( - rect: SpriteRect, - anchor: SpriteAnchor, -): { x: number; y: number } => ({ - x: rect.x + anchor.x * rect.width, - y: rect.y + anchor.y * rect.height, -}); - /** 현재 transform이 적용된 핀의 요소 로컬 위치 */ export const contactWorldPosition = ( geometry: ContactGeometry, diff --git a/src/renderer/utils/sprite/poseResolver.ts b/src/renderer/utils/sprite/poseResolver.ts index 46cd0fe08..c4306b9be 100644 --- a/src/renderer/utils/sprite/poseResolver.ts +++ b/src/renderer/utils/sprite/poseResolver.ts @@ -5,12 +5,11 @@ import { type SpriteTransform, } from '@src/types/key/sprites'; +import { DEG_TO_RAD, RAD_TO_DEG } from './spriteGeometry'; + // 평균 벡터 길이가 이보다 작으면 방향이 정의되지 않은 것으로 본다 (정반대 각 조합) const DEGENERATE_MEAN_VECTOR_EPSILON = 1e-9; -const DEG_TO_RAD = Math.PI / 180; -const RAD_TO_DEG = 180 / Math.PI; - export interface SpriteTargetResolution { transform: SpriteTransform; imageSrc: string | null; @@ -78,6 +77,12 @@ const preparePoses = (poses: readonly SpritePose[]): PreparedPoses => { return prepared; }; +// 스프라이트가 참조하는 키 요소 id 전체 (중복 제거, poseId 사전순). +// 오버레이 잎의 눌림 구독 목록과 자세 해석이 같은 전처리 결과를 공유한다 +export const spriteTriggerIds = ( + poses: readonly SpritePose[], +): readonly string[] => preparePoses(poses).allTriggers; + // 눌린 키 집합만 읽는 순수 해석. 눌린 순서, 시각, 이전 상태에 의존하지 않는다 export const resolveSpriteTarget = ( sprite: ReactiveSpritePosition, diff --git a/src/renderer/utils/sprite/resizeProjection.ts b/src/renderer/utils/sprite/resizeProjection.ts index 8e4dfe38c..10ccee70e 100644 --- a/src/renderer/utils/sprite/resizeProjection.ts +++ b/src/renderer/utils/sprite/resizeProjection.ts @@ -2,6 +2,8 @@ import type { SpriteRect, SpriteTransform } from '@src/types/key/sprites'; import { SPRITE_CONSTRAINTS } from '@src/types/key/sprites'; import type { EditorBoundsV1 } from '@src/types/editor'; +import { clamp } from '@utils/core/clamp'; + // 스프라이트 리사이즈 projection - resizeSprite op의 프론트 구현. // 백엔드 적용기와 분기·연산 순서(나눗셈 1회, 값마다 곱셈 1회, clamp는 // max→min)를 비트 단위로 공유한다. eager 적용·낙관 target·conflict rebase가 @@ -17,9 +19,6 @@ export interface SpriteResizeProjectable { poses: ReadonlyArray<{ transform: SpriteTransform }>; } -const clamp = (value: number, min: number, max: number): number => - Math.min(max, Math.max(min, value)); - // 유한 비음수 배율. 무효 prev/next와 subnormal 나눗셈 오버플로는 1(무배율)로, // 극단 축소의 언더플로 0은 축소 의도 보존을 위해 그대로 허용한다 export const spriteResizeRatio = (prev: number, next: number): number => { diff --git a/src/renderer/utils/sprite/spriteGeometry.test.ts b/src/renderer/utils/sprite/spriteGeometry.test.ts new file mode 100644 index 000000000..8cca00125 --- /dev/null +++ b/src/renderer/utils/sprite/spriteGeometry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { anchorPx, anchorToPercent, percentToAnchor } from './spriteGeometry'; + +describe('spriteGeometry', () => { + it('앵커를 이미지 상자 안의 로컬 px로 옮긴다', () => { + const rect = { x: 10, y: 20, width: 200, height: 100 }; + expect(anchorPx(rect, { x: 0.5, y: 1 })).toEqual({ x: 110, y: 120 }); + expect(anchorPx(rect, { x: 0, y: 0 })).toEqual({ x: 10, y: 20 }); + }); + + it('백분율 표시는 소수 첫째 자리까지 반올림한다', () => { + expect(anchorToPercent(0.5)).toBe(50); + expect(anchorToPercent(0.1234)).toBe(12.3); + expect(anchorToPercent(1)).toBe(100); + }); + + it('백분율을 되돌릴 때 앵커 범위를 벗어나지 않는다', () => { + expect(percentToAnchor(50)).toBe(0.5); + expect(percentToAnchor(-40)).toBe(0); + expect(percentToAnchor(1000)).toBe(1); + }); + + it('표시와 되돌리기가 왕복한다', () => { + for (const value of [0, 0.25, 0.5, 0.999, 1]) { + expect(percentToAnchor(anchorToPercent(value))).toBeCloseTo(value, 3); + } + }); +}); diff --git a/src/renderer/utils/sprite/spriteGeometry.ts b/src/renderer/utils/sprite/spriteGeometry.ts new file mode 100644 index 000000000..7e506d2ae --- /dev/null +++ b/src/renderer/utils/sprite/spriteGeometry.ts @@ -0,0 +1,29 @@ +import { SPRITE_CONSTRAINTS } from '@src/types/key/sprites'; +import type { SpriteAnchor, SpriteRect } from '@src/types/key/sprites'; + +import { clamp } from '@utils/core/clamp'; + +// 스프라이트 좌표 변환 프리미티브. 도달 범위·핀 역산·기즈모·패널이 공유한다 + +export const DEG_TO_RAD = Math.PI / 180; +export const RAD_TO_DEG = 180 / Math.PI; + +/** 이미지 정규화 앵커를 요소 로컬 px로 */ +export const anchorPx = ( + rect: SpriteRect, + anchor: SpriteAnchor, +): { x: number; y: number } => ({ + x: rect.x + anchor.x * rect.width, + y: rect.y + anchor.y * rect.height, +}); + +// 정규화 앵커 [0, 1]과 UI 백분율의 왕복. 표시는 소수 첫째 자리까지 +export const anchorToPercent = (value: number): number => + Math.round(value * 1000) / 10; + +export const percentToAnchor = (percent: number): number => + clamp( + percent, + SPRITE_CONSTRAINTS.anchor.min * 100, + SPRITE_CONSTRAINTS.anchor.max * 100, + ) / 100; diff --git a/src/renderer/utils/sprite/spriteImageStyles.ts b/src/renderer/utils/sprite/spriteImageStyles.ts index f86babadc..d19032b19 100644 --- a/src/renderer/utils/sprite/spriteImageStyles.ts +++ b/src/renderer/utils/sprite/spriteImageStyles.ts @@ -1,11 +1,15 @@ import type { CSSProperties } from 'react'; import { DEFAULT_SPRITE_IMAGE_FIT, - spriteTransformToCss, type ReactiveSpritePosition, type SpriteTransform, } from '@src/types/key/sprites'; +// 자세 변환의 CSS 표현. transform-origin은 pivot이 맡으므로 여기서는 +// translate → rotate → scale 순서만 고정한다 +export const spriteTransformToCss = (transform: SpriteTransform): string => + `translate(${transform.x}px, ${transform.y}px) rotate(${transform.rotation}deg) scale(${transform.scale})`; + // 외관 채널 계산에 필요한 최소 필드 type SpriteImageStyleSource = Pick< ReactiveSpritePosition, diff --git a/src/renderer/utils/sprite/spriteReach.ts b/src/renderer/utils/sprite/spriteReach.ts index 0c92cbb3a..1558bfab2 100644 --- a/src/renderer/utils/sprite/spriteReach.ts +++ b/src/renderer/utils/sprite/spriteReach.ts @@ -3,6 +3,8 @@ import type { SpriteTransform, } from '@src/types/key/sprites'; +import { anchorPx } from './spriteGeometry'; + // 스프라이트 이미지 도달 범위 계산. // 저장된 모든 상태(idle + pose)의 imageRect에 pivot 기준 scale·rotation·offset을 // 적용한 AABB의 합집합이다. 창 바운즈가 이 값을 포함해야 이미지가 활동 영역을 @@ -177,8 +179,7 @@ export const computeSpriteReachAabb = ( ); const { imageRect, pivot } = sprite; - const pivotX = imageRect.x + pivot.x * imageRect.width; - const pivotY = imageRect.y + pivot.y * imageRect.height; + const { x: pivotX, y: pivotY } = anchorPx(imageRect, pivot); // pivot 기준 상대 좌표 네 모서리 const corners: Array<[number, number]> = [ [imageRect.x - pivotX, imageRect.y - pivotY], diff --git a/src/types/key/sprites.ts b/src/types/key/sprites.ts index b2e1cee46..247b20c63 100644 --- a/src/types/key/sprites.ts +++ b/src/types/key/sprites.ts @@ -264,6 +264,3 @@ export const findDuplicateTriggerPose = ( } return null; }; - -export const spriteTransformToCss = (transform: SpriteTransform): string => - `translate(${transform.x}px, ${transform.y}px) rotate(${transform.rotation}deg) scale(${transform.scale})`; From 86f2bd30a75835aa5b741d889aa195a95b42f472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:17:22 +0900 Subject: [PATCH 031/125] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=ED=91=9C?= =?UTF-8?q?=EB=A9=B4=EA=B3=BC=20=EC=A3=BD=EC=9D=80=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/preset/save.rs | 2 +- src-tauri/src/models/mod.rs | 8 +--- .../single/SpritePoseEditorPopup.tsx | 48 +++++++------------ .../main/Grid/handles/SpritePoseGizmo.tsx | 1 - .../main/common/TransformGlyphs.tsx | 2 - .../overlay/counters/OverlaySpriteItem.tsx | 27 ++++++----- src/renderer/locales/en.json | 1 - src/renderer/locales/ko.json | 1 - src/renderer/locales/ru.json | 1 - src/renderer/locales/zh-Hant.json | 1 - src/renderer/locales/zh-cn.json | 1 - src/renderer/stores/signals/keySignals.ts | 2 +- src/types/key/sprites.ts | 1 - 13 files changed, 37 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/commands/preset/save.rs b/src-tauri/src/commands/preset/save.rs index 8720f93b0..ecfcca2f8 100644 --- a/src-tauri/src/commands/preset/save.rs +++ b/src-tauri/src/commands/preset/save.rs @@ -418,7 +418,7 @@ fn build_preset_font_payload( )) } -pub(crate) fn build_preset_image_payload( +pub(super) fn build_preset_image_payload( key_positions: &KeyPositions, stat_positions: &StatPositions, graph_positions: &GraphPositions, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 377627425..136988321 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -963,10 +963,6 @@ pub enum SpriteActivation { OnPress, } -pub(crate) fn default_sprite_activation() -> SpriteActivation { - SpriteActivation::WhileHeld -} - pub(crate) fn default_sprite_press_duration_ms() -> u32 { 300 } @@ -1050,7 +1046,7 @@ pub struct ReactiveSpritePosition { pub idle_transform: SpriteTransform, #[serde(default)] pub poses: Vec, - #[serde(default = "default_sprite_activation")] + #[serde(default)] pub activation: SpriteActivation, #[serde(default = "default_sprite_press_duration_ms")] pub press_duration_ms: u32, @@ -1080,7 +1076,7 @@ impl Default for ReactiveSpritePosition { pivot: SpriteAnchor::default(), idle_transform: SpriteTransform::default(), poses: Vec::new(), - activation: default_sprite_activation(), + activation: SpriteActivation::default(), press_duration_ms: default_sprite_press_duration_ms(), transition_ms: default_sprite_transition_ms(), transition_easing: default_sprite_transition_easing(), diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx index 23b564734..79aa13188 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -18,7 +18,7 @@ import { anchorToPercent, percentToAnchor } from '@utils/sprite/spriteGeometry'; import SpriteImagePreviewCard from './SpriteImagePreviewCard'; // 담당 키·이미지 오버라이드 컨트롤 묶음 (삭제·이름 변경은 행 메뉴가 맡는다) -export interface SpritePoseControls { +interface SpritePoseControls { keyOptions: ReadonlyArray<{ id: string; label: string }>; triggers: readonly string[]; isDuplicate: boolean; @@ -31,7 +31,7 @@ export interface SpritePoseControls { } // 손끝 핀 컨트롤 묶음 - 값은 캔버스 노브(Alt 드래그)와 같은 contactPoint -export interface SpritePosePinControls { +interface SpritePosePinControls { contactPoint: SpriteAnchor; /** 회전·배율 스크럽이 손끝을 제자리에 두도록 x·y를 역산 */ pinLock: boolean; @@ -56,7 +56,7 @@ interface SpritePoseEditorPopupProps { // 행 전환 시 바깥닫힘을 거치지 않는 영역 (상태 목록 well) interactiveRefs?: React.RefObject[]; onTransformCommit: (next: SpriteTransform) => void; - onTransformPreview?: (next: SpriteTransform) => void; + onTransformPreview: (next: SpriteTransform) => void; onTransformCancel: () => void; onClose: () => void; t: (key: string) => string; @@ -85,10 +85,8 @@ const SpritePoseEditorPopup: React.FC = ({ const { offset, rotation, scale } = SPRITE_CONSTRAINTS; const commitField = (patch: Partial) => onTransformCommit({ ...transform, ...patch }); - const previewField = onTransformPreview - ? (patch: Partial) => - onTransformPreview({ ...transform, ...patch }) - : undefined; + const previewField = (patch: Partial) => + onTransformPreview({ ...transform, ...patch }); const keyOptionIds = new Set( poseControls.keyOptions.map((option) => option.id), @@ -130,11 +128,8 @@ const SpritePoseEditorPopup: React.FC = ({ onChange={(value) => commitField({ x: clamp(value, offset.min, offset.max) }) } - onPreview={ - previewField - ? (value) => - previewField({ x: clamp(value, offset.min, offset.max) }) - : undefined + onPreview={(value) => + previewField({ x: clamp(value, offset.min, offset.max) }) } onCancel={onTransformCancel} prefix="X" @@ -150,11 +145,8 @@ const SpritePoseEditorPopup: React.FC = ({ onChange={(value) => commitField({ y: clamp(value, offset.min, offset.max) }) } - onPreview={ - previewField - ? (value) => - previewField({ y: clamp(value, offset.min, offset.max) }) - : undefined + onPreview={(value) => + previewField({ y: clamp(value, offset.min, offset.max) }) } onCancel={onTransformCancel} prefix="Y" @@ -174,13 +166,10 @@ const SpritePoseEditorPopup: React.FC = ({ rotation: clamp(value, rotation.min, rotation.max), }) } - onPreview={ - previewField - ? (value) => - previewField({ - rotation: clamp(value, rotation.min, rotation.max), - }) - : undefined + onPreview={(value) => + previewField({ + rotation: clamp(value, rotation.min, rotation.max), + }) } onCancel={onTransformCancel} prefix={} @@ -198,13 +187,10 @@ const SpritePoseEditorPopup: React.FC = ({ onChange={(value) => commitField({ scale: clamp(value / 100, scale.min, scale.max) }) } - onPreview={ - previewField - ? (value) => - previewField({ - scale: clamp(value / 100, scale.min, scale.max), - }) - : undefined + onPreview={(value) => + previewField({ + scale: clamp(value / 100, scale.min, scale.max), + }) } onCancel={onTransformCancel} prefix={} diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx index c6a09d3a3..7d0e0046a 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -317,7 +317,6 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { ref={rootRef} className="absolute inset-0 pointer-events-none" style={{ zIndex: 'var(--z-canvas-gradient-editor)' }} - data-sprite-pose-gizmo="true" > {/* 축→핀 안내선 */} ( diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 624e866e0..73aa42806 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -62,8 +62,9 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ } } - const target = isOneShot - ? { transform: position.idleTransform, imageSrc: position.baseImage } + // onPress는 렌더가 idle 고정이라 자세 해석과 폴백 계산을 타지 않는다 + const heldTarget = isOneShot + ? null : resolveSpriteTarget(position, pressedIds); const { failedImageSrcs, markFailed } = useFailedImageSrcs( @@ -72,7 +73,6 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ ); const baseImageSrc = resolveImageSource(position.baseImage); - const targetImageSrc = resolveImageSource(target.imageSrc); // easing 해석은 정규식·베지어 극값 계산이라 렌더마다 돌리지 않는다. // 발산 easing은 도달 계산과 같은 기준으로 강등 (창 클리핑 방지) @@ -86,9 +86,13 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ : `transform ${position.transitionMs}ms ${renderEasing}`; // 실패한 src는 baseImage로 폴백, 그것도 실패면 렌더 제외 - let imageSrc = targetImageSrc; - if (imageSrc && failedImageSrcs.has(imageSrc)) imageSrc = baseImageSrc; - if (imageSrc && failedImageSrcs.has(imageSrc)) imageSrc = null; + let heldImageSrc = heldTarget + ? resolveImageSource(heldTarget.imageSrc) + : null; + if (heldImageSrc && failedImageSrcs.has(heldImageSrc)) { + heldImageSrc = baseImageSrc; + } + if (heldImageSrc && failedImageSrcs.has(heldImageSrc)) heldImageSrc = null; // 첫 자세 전환에서 cold decode가 겹치지 않도록 base와 모든 override를 선행 디코드 const warmupSrcs = useMemo(() => { @@ -305,25 +309,26 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ }} /> ) - : imageSrc && ( + : heldTarget && + heldImageSrc && ( { - if (!isErrorForCurrentSrc(event.currentTarget, imageSrc)) + if (!isErrorForCurrentSrc(event.currentTarget, heldImageSrc)) return; - markFailed(imageSrc); + markFailed(heldImageSrc); }} /> )} diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 4cb7282a7..f49d9e749 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -563,7 +563,6 @@ "spriteAddPose": "Add State", "spriteTriggerKeys": "Trigger Keys", "spriteTriggerPlaceholder": "Select keys", - "spriteImageOverride": "State Image", "spriteMissingKey": "Deleted key", "spriteNoKeys": "No key elements in this mode", "spriteDuplicateTriggers": "Duplicate key combination", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 22dedce2d..506010d49 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -563,7 +563,6 @@ "spriteAddPose": "상태 추가", "spriteTriggerKeys": "담당 키", "spriteTriggerPlaceholder": "키 선택", - "spriteImageOverride": "상태 이미지", "spriteMissingKey": "삭제된 키", "spriteNoKeys": "이 모드에 키 요소가 없습니다", "spriteDuplicateTriggers": "중복된 키 조합입니다", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index 91cf3f6cf..4c874a792 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -568,7 +568,6 @@ "spriteAddPose": "Добавить состояние", "spriteTriggerKeys": "Клавиши позы", "spriteTriggerPlaceholder": "Выберите клавиши", - "spriteImageOverride": "Картинка состояния", "spriteMissingKey": "Удалённая клавиша", "spriteNoKeys": "В этом режиме нет клавиш", "spriteDuplicateTriggers": "Такая комбинация клавиш уже есть", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index a2d3759a7..c1e5256d7 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -568,7 +568,6 @@ "spriteAddPose": "新增狀態", "spriteTriggerKeys": "觸發按鍵", "spriteTriggerPlaceholder": "選擇按鍵", - "spriteImageOverride": "狀態圖像", "spriteMissingKey": "已刪除的按鍵", "spriteNoKeys": "此模式沒有按鍵元素", "spriteDuplicateTriggers": "按鍵組合重複", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 113abdbf5..b0f720102 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -568,7 +568,6 @@ "spriteAddPose": "添加状态", "spriteTriggerKeys": "触发按键", "spriteTriggerPlaceholder": "选择按键", - "spriteImageOverride": "状态图像", "spriteMissingKey": "已删除的按键", "spriteNoKeys": "此模式没有按键元素", "spriteDuplicateTriggers": "按键组合重复", diff --git a/src/renderer/stores/signals/keySignals.ts b/src/renderer/stores/signals/keySignals.ts index 19d4e8c73..c98ce58fc 100644 --- a/src/renderer/stores/signals/keySignals.ts +++ b/src/renderer/stores/signals/keySignals.ts @@ -43,7 +43,7 @@ export function subscribeKeyPressEdge( }; } -export function emitKeyPressEdge(key: string) { +function emitKeyPressEdge(key: string) { const listeners = pressEdgeListeners.get(key); if (!listeners) return; for (const listener of [...listeners]) listener(); diff --git a/src/types/key/sprites.ts b/src/types/key/sprites.ts index 247b20c63..d9df1bceb 100644 --- a/src/types/key/sprites.ts +++ b/src/types/key/sprites.ts @@ -182,7 +182,6 @@ export const spritePoseInputSchema = z.object({ // 구 플러그인 patch는 필드 자체가 없다 - 생략 허용, BE serde default가 채움 contactPoint: spriteAnchorSchema.optional(), }); -export type SpritePoseInput = z.infer; const reactiveSpritePositionBaseShape = { // 백엔드 발급 요소 id. 발급 전에는 키 생략만 허용 - 명시 null은 Rust String From 8a7ba922b9e5ad773b3db7afbfbaca018abcc388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:18:59 +0900 Subject: [PATCH 032/125] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=83=81=EC=9E=90=20=ED=8E=B8=EC=A7=91=20=ED=95=9C=EA=B3=84?= =?UTF-8?q?=20=EB=AA=85=EB=AA=85=EA=B3=BC=20=EB=85=B8=EB=B8=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=ED=81=AC=EA=B8=B0=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SpriteImageSettingsPopup.tsx | 68 +++++++++++++++---- .../main/Grid/handles/SpritePoseGizmo.tsx | 1 - .../hooks/shared/useLayoutComputation.ts | 4 +- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx index 87ecca4b4..c4e86bec3 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImageSettingsPopup.tsx @@ -11,8 +11,20 @@ import { type ReactiveSpritePosition, type SpriteImageFit, } from '@src/types/key/sprites'; +import { clamp } from '@utils/core/clamp'; import SpriteImagePreviewCard from './SpriteImagePreviewCard'; +// 이미지 상자 입력의 편집 한계. 저장 계약(SPRITE_CONSTRAINTS.imageRect, +// 좌표 ±32768 / 치수 하한 0.000001)보다 좁다 - 손으로 칠 수 있는 범위만 열고 +// 그 밖의 값은 리사이즈 배율과 플러그인 patch가 만든다. 좁히기만 하므로 +// 기존 값은 표시·보존되고 이 필드를 직접 건드릴 때만 범위로 접힌다 +const IMAGE_RECT_EDIT_LIMITS = { + coordMin: -9999, + coordMax: 9999, + dimensionMin: 1, + dimensionMax: 9999, +} as const; + interface SpriteImageSettingsPopupProps { open: boolean; position: Pick< @@ -84,8 +96,8 @@ const SpriteImageSettingsPopup: React.FC = ({ prefix="X" ariaLabel={`${t('propertiesPanel.spriteImageRect') || '위치'} X`} width="100%" - min={-9999} - max={9999} + min={IMAGE_RECT_EDIT_LIMITS.coordMin} + max={IMAGE_RECT_EDIT_LIMITS.coordMax} allowDecimal decimalScale={1} /> @@ -97,8 +109,8 @@ const SpriteImageSettingsPopup: React.FC = ({ prefix="Y" ariaLabel={`${t('propertiesPanel.spriteImageRect') || '위치'} Y`} width="100%" - min={-9999} - max={9999} + min={IMAGE_RECT_EDIT_LIMITS.coordMin} + max={IMAGE_RECT_EDIT_LIMITS.coordMax} allowDecimal decimalScale={1} /> @@ -107,34 +119,66 @@ const SpriteImageSettingsPopup: React.FC = ({ - onCommit(rectField({ width: Math.max(1, value) })) + onCommit( + rectField({ + width: clamp( + value, + IMAGE_RECT_EDIT_LIMITS.dimensionMin, + IMAGE_RECT_EDIT_LIMITS.dimensionMax, + ), + }), + ) } onPreview={(value) => - onPreview(rectField({ width: Math.max(1, value) })) + onPreview( + rectField({ + width: clamp( + value, + IMAGE_RECT_EDIT_LIMITS.dimensionMin, + IMAGE_RECT_EDIT_LIMITS.dimensionMax, + ), + }), + ) } onCancel={onCancel} prefix="W" ariaLabel={`${t('propertiesPanel.spriteImageSize') || '크기'} W`} width="100%" - min={1} - max={9999} + min={IMAGE_RECT_EDIT_LIMITS.dimensionMin} + max={IMAGE_RECT_EDIT_LIMITS.dimensionMax} allowDecimal decimalScale={1} /> - onCommit(rectField({ height: Math.max(1, value) })) + onCommit( + rectField({ + height: clamp( + value, + IMAGE_RECT_EDIT_LIMITS.dimensionMin, + IMAGE_RECT_EDIT_LIMITS.dimensionMax, + ), + }), + ) } onPreview={(value) => - onPreview(rectField({ height: Math.max(1, value) })) + onPreview( + rectField({ + height: clamp( + value, + IMAGE_RECT_EDIT_LIMITS.dimensionMin, + IMAGE_RECT_EDIT_LIMITS.dimensionMax, + ), + }), + ) } onCancel={onCancel} prefix="H" ariaLabel={`${t('propertiesPanel.spriteImageSize') || '크기'} H`} width="100%" - min={1} - max={9999} + min={IMAGE_RECT_EDIT_LIMITS.dimensionMin} + max={IMAGE_RECT_EDIT_LIMITS.dimensionMax} allowDecimal decimalScale={1} /> diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx index 7d0e0046a..6b2911fee 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -349,7 +349,6 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => {
Date: Tue, 1 Sep 2026 04:20:06 +0900 Subject: [PATCH 033/125] =?UTF-8?q?docs:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20C?= =?UTF-8?q?SS=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EC=99=80=20=EC=84=A0=ED=83=9D=EC=9E=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/custom-css/variables/page.mdx | 28 +++++++++++++++++++ docs/content/ko/custom-css/variables/page.mdx | 27 ++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index 6da3315cd..89e67a5c1 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -186,6 +186,26 @@ resets it together with its spacing. } ``` +## Sprite Style Variables + +Reactive sprites render a single image layer that switches poses while its +trigger keys are held. Placement and the rotation origin stay inline because the +window bounds are computed from them; the appearance channels below are the ones +you can replace. Disable **Inline Styles Priority** on the sprite to use them. + +| Variable | Description | Type | Example | +| --------------------- | ------------------------------------------- | -------------- | -------------------------- | +| `--sprite-fit` | Replacement fit for the sprite image | `` | `cover`, `contain` | +| `--sprite-transform` | Replacement transform for the resolved pose | `` | `rotate(15deg) scale(1.2)` | +| `--sprite-transition` | Replacement transition for the pose change | `` | `transform 120ms ease-out` | + +```css +.left-hand [data-sprite-element] > img { + --sprite-fit: cover; + --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1); +} +``` + ## Selector Reference ### Key Selectors @@ -225,6 +245,14 @@ resets it together with its spacing. | `.knobClass [data-knob-element]` | A specific class's knob surface | | `[data-knob-indicator]` | Center rotation indicator | +### Sprite Selectors + +| Selector | Description | +| ------------------------------------ | -------------------------- | +| `[data-sprite-element]` | Every sprite activity area | +| `[data-sprite-element] > img` | The sprite image layer | +| `.spriteClass [data-sprite-element]` | A specific class's sprite | + ## Standard CSS Properties With **Inline Styles Priority** disabled, you can directly use standard visual diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx index c4f7b39df..ca9d4f118 100644 --- a/docs/content/ko/custom-css/variables/page.mdx +++ b/docs/content/ko/custom-css/variables/page.mdx @@ -182,6 +182,25 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 } ``` +## 스프라이트 스타일 변수 + +반응형 스프라이트는 담당 키를 누르는 동안 자세가 바뀌는 이미지 한 층을 그립니다. +배치와 회전 기준점은 창 크기 계산에 그대로 들어가므로 인라인으로 남고, 아래 +외관 채널만 교체할 수 있습니다. 스프라이트의 **인라인 스타일 우선**을 끄고 쓰세요. + +| 변수 | 설명 | 타입 | 예시 | +| --------------------- | --------------------------- | -------------- | -------------------------- | +| `--sprite-fit` | 스프라이트 이미지 맞춤 교체 | `` | `cover`, `contain` | +| `--sprite-transform` | 해석된 자세의 변환 교체 | `` | `rotate(15deg) scale(1.2)` | +| `--sprite-transition` | 자세 전환 트랜지션 교체 | `` | `transform 120ms ease-out` | + +```css +.왼손 [data-sprite-element] > img { + --sprite-fit: cover; + --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1); +} +``` + ## 선택자 레퍼런스 ### 키 선택자 @@ -221,6 +240,14 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 | `.노브클래스 [data-knob-element]` | 특정 클래스의 노브 표면 | | `[data-knob-indicator]` | 노브 중앙 회전 표시 | +### 스프라이트 선택자 + +| 선택자 | 설명 | +| ----------------------------------------- | ------------------------- | +| `[data-sprite-element]` | 모든 스프라이트 활동 영역 | +| `[data-sprite-element] > img` | 스프라이트 이미지 층 | +| `.스프라이트클래스 [data-sprite-element]` | 특정 클래스의 스프라이트 | + ## 표준 CSS 속성 **인라인 스타일 우선**이 꺼진 요소에는 다음과 같은 표준 외형 속성을 직접 사용할 From b47e2a244af8a631eddff8a4668a99e7d5bd4c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:22:41 +0900 Subject: [PATCH 034/125] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=90=EC=84=B8=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=88=9C=EC=88=98=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.tsx | 106 +++++------------- .../utils/sprite/spritePoseNames.test.ts | 92 +++++++++++++++ src/renderer/utils/sprite/spritePoseNames.ts | 79 +++++++++++++ 3 files changed, 201 insertions(+), 76 deletions(-) create mode 100644 src/renderer/utils/sprite/spritePoseNames.test.ts create mode 100644 src/renderer/utils/sprite/spritePoseNames.ts diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index 40eb7e4dd..1b5131135 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -11,6 +11,11 @@ import { pickValidatedImagePath } from '@utils/core/pickValidatedImage'; import { anchorToPercent, percentToAnchor } from '@utils/sprite/spriteGeometry'; import { isHTMLElementNode } from '@utils/dom/isElementNode'; import { projectSpriteResize } from '@utils/sprite/resizeProjection'; +import { + copyPoseName, + materializePoseNames, + resolvePoseNames, +} from '@utils/sprite/spritePoseNames'; import { toSpriteWireShape } from '@utils/sprite/spriteWireShape'; import { slotDisplayName } from '@utils/keySlot'; import { ACTION_BUTTON_CLASS, AXIS_FIELD_WIDTH } from '@utils/cardRecipes'; @@ -548,7 +553,7 @@ export const SingleSpritePanel: React.FC = ({ const addPose = (anchor: HTMLElement) => { if (displayPoses.length >= SPRITE_CONSTRAINTS.maxPoses) return; - const materialized = materializePoseNames(displayPoses); + const materialized = materializePoseNames(displayPoses, poseNameLabel); const pose: SpritePose = { poseId: crypto.randomUUID(), name: nextDefaultPoseName(materialized), @@ -572,45 +577,14 @@ export const SingleSpritePanel: React.FC = ({ const removePose = (poseIndex: number) => updatePoses( - materializePoseNames(displayPoses).filter( + materializePoseNames(displayPoses, poseNameLabel).filter( (_, index) => index !== poseIndex, ), ); - // "이름 복제"·"이름 복제 3"을 다시 복제해도 루트를 유지하고 숫자만 올린다. - // 카운터는 2부터만 생성되므로 "복제 0"·"복제 01" 같은 이름은 사용자 작명으로 보존 - const stripCopySuffix = (name: string, suffix: string): string => { - const marker = ` ${suffix}`; - const markerIndex = name.lastIndexOf(marker); - if (markerIndex <= 0) return name; - const tail = name.slice(markerIndex + marker.length); - if (tail === '') return name.slice(0, markerIndex); - const digits = tail.startsWith(' ') ? tail.slice(1) : ''; - const isGeneratedCounter = - /^\d+$/.test(digits) && - digits === String(Number(digits)) && - Number(digits) >= 2; - return isGeneratedCounter ? name.slice(0, markerIndex) : name; - }; - - // 사본 이름: 원본 이름 + 복제 접미사, 겹치면 2부터 숫자를 올린다 - const copyPoseName = (poses: SpritePose[], sourceIndex: number): string => { - const suffix = t('common.copySuffix') || '복제'; - const base = poses[sourceIndex].name || resolvedNames[sourceIndex]; - const root = stripCopySuffix(base, suffix); - const usedNames = new Set( - poses.flatMap((pose) => (pose.name ? [pose.name] : [])), - ); - let candidate = `${root} ${suffix}`; - for (let counter = 2; usedNames.has(candidate); counter += 1) { - candidate = `${root} ${suffix} ${counter}`; - } - return candidate; - }; - const clonePose = (sourcePoseId: string) => { if (displayPoses.length >= SPRITE_CONSTRAINTS.maxPoses) return; - const materialized = materializePoseNames(displayPoses); + const materialized = materializePoseNames(displayPoses, poseNameLabel); const sourceIndex = materialized.findIndex( (entry) => entry.poseId === sourcePoseId, ); @@ -619,7 +593,12 @@ export const SingleSpritePanel: React.FC = ({ const pose: SpritePose = { ...source, poseId: crypto.randomUUID(), - name: copyPoseName(materialized, sourceIndex), + name: copyPoseName( + materialized, + sourceIndex, + poseNameLabel, + t('common.copySuffix') || '복제', + ), transform: { ...source.transform }, // 같은 트리거 집합은 저장이 거부되므로 담당 키는 비워서 시작한다. // 빈 트리거 draft는 상태 추가와 같은 보류 경로를 탄다 @@ -667,49 +646,23 @@ export const SingleSpritePanel: React.FC = ({ }, [renamingPoseId]); const poseNameLabel = t('propertiesPanel.spritePose') || '상태'; - - // 무명(name=null) 상태에 줄 번호를 표시·고정 공용으로 계산한다. - // 저장된 '상태 N'이 점유한 번호는 건너뛰므로 명시 이름과 혼재해도 중복이 없다 - const resolvedPoseNames = (poses: readonly SpritePose[]): string[] => { - const prefix = `${poseNameLabel} `; - const used = new Set(); - for (const pose of poses) { - if (!pose.name?.startsWith(prefix)) continue; - const digits = pose.name.slice(prefix.length); - if (!/^\d+$/.test(digits) || digits !== String(Number(digits))) continue; - used.add(Number(digits)); - } - let next = 1; - return poses.map((pose) => { - if (pose.name) return pose.name; - while (used.has(next)) next += 1; - used.add(next); - return `${poseNameLabel} ${next}`; - }); - }; - const resolvedNames = resolvedPoseNames(displayPoses); - - // 구조 변경(추가·복제·삭제) 직전에 무명 상태의 현재 표시 번호를 이름으로 고정한다. - // 표시값 그대로 저장하므로 화면 변화는 없고, 이후 삽입·삭제에도 번호가 유지된다 (sticky) - const materializePoseNames = (poses: SpritePose[]): SpritePose[] => { - const names = resolvedPoseNames(poses); - return poses.map((pose, index) => - pose.name ? pose : { ...pose, name: names[index] }, - ); - }; + const resolvedNames = resolvePoseNames(displayPoses, poseNameLabel); // 새 상태 기본 이름 - 점유된 '상태 N' 중 비어 있는 가장 작은 번호 const nextDefaultPoseName = (poses: SpritePose[]): string => - resolvedPoseNames([ - ...poses, - { - poseId: 'next', - triggers: [], - transform: IDENTITY_SPRITE_TRANSFORM, - imageOverride: null, - contactPoint: DEFAULT_SPRITE_CONTACT_POINT, - }, - ])[poses.length]; + resolvePoseNames( + [ + ...poses, + { + poseId: 'next', + triggers: [], + transform: IDENTITY_SPRITE_TRANSFORM, + imageOverride: null, + contactPoint: DEFAULT_SPRITE_CONTACT_POINT, + }, + ], + poseNameLabel, + )[poses.length]; const startPoseRename = (poseId: string) => { const poseIndex = displayPoses.findIndex( @@ -743,10 +696,11 @@ export const SingleSpritePanel: React.FC = ({ // 자기 이름을 뺀 점유 번호 기준으로 빈 번호를 준다 const nextName = trimmed === '' - ? resolvedPoseNames( + ? resolvePoseNames( displayPoses.map((pose, index) => index === poseIndex ? { ...pose, name: null } : pose, ), + poseNameLabel, )[poseIndex] : trimmed; if ((displayPoses[poseIndex].name ?? null) === nextName) return; diff --git a/src/renderer/utils/sprite/spritePoseNames.test.ts b/src/renderer/utils/sprite/spritePoseNames.test.ts new file mode 100644 index 000000000..43ef7b6ad --- /dev/null +++ b/src/renderer/utils/sprite/spritePoseNames.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import type { SpritePose } from '@src/types/key/sprites'; + +import { + copyPoseName, + materializePoseNames, + resolvePoseNames, + stripCopySuffix, +} from './spritePoseNames'; + +const LABEL = '상태'; +const SUFFIX = '복제'; + +const pose = (poseId: string, name: string | null = null): SpritePose => ({ + poseId, + name, + triggers: [], + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + contactPoint: { x: 0.5, y: 1 }, +}); + +describe('resolvePoseNames', () => { + it('무명 자세에 1부터 번호를 매긴다', () => { + expect(resolvePoseNames([pose('a'), pose('b')], LABEL)).toEqual([ + '상태 1', + '상태 2', + ]); + }); + + it('저장된 번호가 점유한 자리는 건너뛴다', () => { + expect( + resolvePoseNames([pose('a'), pose('b', '상태 1'), pose('c')], LABEL), + ).toEqual(['상태 2', '상태 1', '상태 3']); + }); + + it('사용자 작명은 그대로 둔다', () => { + expect(resolvePoseNames([pose('a', '왼손'), pose('b')], LABEL)).toEqual([ + '왼손', + '상태 1', + ]); + }); + + it('앞자리 0이 붙은 이름은 생성 번호로 보지 않는다', () => { + expect(resolvePoseNames([pose('a', '상태 01'), pose('b')], LABEL)).toEqual([ + '상태 01', + '상태 1', + ]); + }); +}); + +describe('materializePoseNames', () => { + it('무명만 표시값으로 고정하고 나머지 참조는 보존한다', () => { + const named = pose('b', '왼손'); + const result = materializePoseNames([pose('a'), named], LABEL); + expect(result[0].name).toBe('상태 1'); + expect(result[1]).toBe(named); + }); +}); + +describe('stripCopySuffix', () => { + it('생성된 복제 접미사만 벗긴다', () => { + expect(stripCopySuffix('왼손 복제', SUFFIX)).toBe('왼손'); + expect(stripCopySuffix('왼손 복제 3', SUFFIX)).toBe('왼손'); + }); + + it('사용자가 지은 복제 0·복제 01은 이름의 일부로 본다', () => { + expect(stripCopySuffix('왼손 복제 0', SUFFIX)).toBe('왼손 복제 0'); + expect(stripCopySuffix('왼손 복제 01', SUFFIX)).toBe('왼손 복제 01'); + }); + + it('이름 전체가 접미사면 벗기지 않는다', () => { + expect(stripCopySuffix('복제', SUFFIX)).toBe('복제'); + }); +}); + +describe('copyPoseName', () => { + it('겹치면 2부터 숫자를 올린다', () => { + const poses = [pose('a', '왼손'), pose('b', '왼손 복제')]; + expect(copyPoseName(poses, 0, LABEL, SUFFIX)).toBe('왼손 복제 2'); + }); + + it('사본을 다시 복제해도 루트를 유지한다', () => { + const poses = [pose('a', '왼손 복제 3')]; + expect(copyPoseName(poses, 0, LABEL, SUFFIX)).toBe('왼손 복제'); + }); + + it('무명 자세는 표시 번호를 루트로 쓴다', () => { + expect(copyPoseName([pose('a')], 0, LABEL, SUFFIX)).toBe('상태 1 복제'); + }); +}); diff --git a/src/renderer/utils/sprite/spritePoseNames.ts b/src/renderer/utils/sprite/spritePoseNames.ts new file mode 100644 index 000000000..1e497499e --- /dev/null +++ b/src/renderer/utils/sprite/spritePoseNames.ts @@ -0,0 +1,79 @@ +import type { SpritePose } from '@src/types/key/sprites'; + +// 자세 이름 규칙 - 표시 번호 부여와 사본 이름 짓기. 라벨·접미사는 로케일에서 +// 오므로 인자로 받고, 여기서는 문자열 규칙만 다룬다 + +/** + * 무명(name=null) 자세에 줄 번호를 매긴다. 저장된 '라벨 N'이 점유한 번호는 + * 건너뛰므로 명시 이름과 섞여도 중복이 없다 + */ +export const resolvePoseNames = ( + poses: readonly SpritePose[], + label: string, +): string[] => { + const prefix = `${label} `; + const used = new Set(); + for (const pose of poses) { + if (!pose.name?.startsWith(prefix)) continue; + const digits = pose.name.slice(prefix.length); + if (!/^\d+$/.test(digits) || digits !== String(Number(digits))) continue; + used.add(Number(digits)); + } + let next = 1; + return poses.map((pose) => { + if (pose.name) return pose.name; + while (used.has(next)) next += 1; + used.add(next); + return `${label} ${next}`; + }); +}; + +/** + * 구조 변경(추가·복제·삭제) 직전에 무명 자세의 현재 표시 번호를 이름으로 고정한다. + * 표시값 그대로 저장하므로 화면은 그대로이고 이후 삽입·삭제에도 번호가 유지된다 + */ +export const materializePoseNames = ( + poses: readonly SpritePose[], + label: string, +): SpritePose[] => { + const names = resolvePoseNames(poses, label); + return poses.map((pose, index) => + pose.name ? pose : { ...pose, name: names[index] }, + ); +}; + +// "이름 복제"·"이름 복제 3"을 다시 복제해도 루트를 유지하고 숫자만 올린다. +// 카운터는 2부터만 생성되므로 "복제 0"·"복제 01" 같은 이름은 사용자 작명으로 보존 +export const stripCopySuffix = (name: string, suffix: string): string => { + const marker = ` ${suffix}`; + const markerIndex = name.lastIndexOf(marker); + if (markerIndex <= 0) return name; + const tail = name.slice(markerIndex + marker.length); + if (tail === '') return name.slice(0, markerIndex); + const digits = tail.startsWith(' ') ? tail.slice(1) : ''; + const isGeneratedCounter = + /^\d+$/.test(digits) && + digits === String(Number(digits)) && + Number(digits) >= 2; + return isGeneratedCounter ? name.slice(0, markerIndex) : name; +}; + +/** 사본 이름: 원본 이름 + 복제 접미사, 겹치면 2부터 숫자를 올린다 */ +export const copyPoseName = ( + poses: readonly SpritePose[], + sourceIndex: number, + label: string, + suffix: string, +): string => { + const base = + poses[sourceIndex].name || resolvePoseNames(poses, label)[sourceIndex]; + const root = stripCopySuffix(base, suffix); + const usedNames = new Set( + poses.flatMap((pose) => (pose.name ? [pose.name] : [])), + ); + let candidate = `${root} ${suffix}`; + for (let counter = 2; usedNames.has(candidate); counter += 1) { + candidate = `${root} ${suffix} ${counter}`; + } + return candidate; +}; From fd8e60c7b0501d66d82920069452c2976d66f8eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:25:20 +0900 Subject: [PATCH 035/125] =?UTF-8?q?refactor:=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B8=94=EB=A1=9D=20=EA=B3=B5=EC=9A=A9=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/PropertiesPanel/PanelRenameTitle.tsx | 90 ++++++++++ .../batch/BatchSelectionPanel.tsx | 56 ++---- .../single/SingleSelectionPanel.tsx | 162 ++++-------------- .../single/SingleSpritePanel.tsx | 55 ++---- 4 files changed, 155 insertions(+), 208 deletions(-) create mode 100644 src/renderer/components/main/Grid/PropertiesPanel/PanelRenameTitle.tsx diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PanelRenameTitle.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PanelRenameTitle.tsx new file mode 100644 index 000000000..5d681c1f9 --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/PanelRenameTitle.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { RenameIcon } from './PanelIcons'; + +// 패널 헤더의 제목과 인라인 이름 변경. 단일 선택 패널 3종·키 계열 패널· +// 배치 그룹 헤더가 같은 규약을 쓴다 (더블클릭 또는 연필 버튼으로 시작, +// Enter는 blur로 커밋, Escape는 취소. blur·Escape 경합은 호출부 ref가 가른다) + +// 계열별 제목 규격 - 그래프·노브·스프라이트는 좁고, 키 계열·배치 그룹은 넓다 +const TITLE_CLASS = { + compact: 'text-fg text-label truncate max-w-[100px] cursor-default', + wide: 'text-fg text-label leading-none cursor-default truncate max-w-[110px]', +} as const; + +interface PanelRenameTitleProps { + title: string; + isRenaming: boolean; + renameValue: string; + setRenameValue: (value: string) => void; + renameInputRef: React.RefObject; + /** Escape 취소가 뒤따르는 blur 커밋을 삼키게 하는 공유 플래그 */ + renameCancelledRef: React.MutableRefObject; + onRenameCommit: (value: string) => void; + onRenameCancel: () => void; + onRenameStart: () => void; + renameLabel: string; + variant?: keyof typeof TITLE_CLASS; +} + +const PanelRenameTitle = ({ + title, + isRenaming, + renameValue, + setRenameValue, + renameInputRef, + renameCancelledRef, + onRenameCommit, + onRenameCancel, + onRenameStart, + renameLabel, + variant = 'compact', +}: PanelRenameTitleProps) => { + if (isRenaming) { + return ( + setRenameValue(e.target.value)} + onBlur={() => { + if (!renameCancelledRef.current) { + onRenameCommit(renameValue); + } + renameCancelledRef.current = false; + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onRenameCancel(); + } + }} + /> + ); + } + + return ( +
+ + {title} + + +
+ ); +}; + +export default PanelRenameTitle; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 717150c0a..dbf58179f 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -33,7 +33,6 @@ import ColorPicker from '@components/main/Modal/content/pickers/ColorPicker'; import PopupExit from '@components/main/Modal/PopupExit'; import ImagePicker from '@components/main/Modal/content/pickers/ImagePicker'; import EditSessionBoundary from '../EditSessionBoundary'; -import { RenameIcon } from '../PanelIcons'; import type { ElementIdSelection } from '@hooks/pickers/useBatchElementBinding'; import { patchActiveImageByTargets, @@ -87,6 +86,7 @@ import { } from '@utils/color/colorUtils'; import type { BatchElementPropertyUpdate } from '../types'; import { useBatchNotePaint, type BatchNoteSurface } from './useBatchNotePaint'; +import PanelRenameTitle from '../PanelRenameTitle'; const NATIVE_IMAGE_TYPES = ['key', 'stat', 'graph', 'knob'] as const; @@ -300,47 +300,19 @@ const BatchPanelHeader: React.FC = ({
{selectedGroupInfo ? ( - isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {selectedGroupInfo.name} - - -
- ) + ) : ( {t('propertiesPanel.multiSelection') || '다중 선택'} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index 20baee1d5..e22442a5d 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -84,7 +84,6 @@ import { AXIS_FIELD_WIDTH, } from '@utils/cardRecipes'; import EditSessionBoundary from '../EditSessionBoundary'; -import { RenameIcon } from '../PanelIcons'; import type { GeometryField } from '@src/renderer/editor/runtime/elementOps'; import type { EditorPaintPropertyPatchV1, @@ -93,6 +92,7 @@ import type { EditorStylePropertyPreviewPatchV1, EditorElementPropertyPatchV1, } from '@src/types/editor'; +import PanelRenameTitle from '../PanelRenameTitle'; const getStatTypeLabel = (statType?: StatItemType | null): string => { switch (statType) { @@ -434,47 +434,18 @@ export const SingleGraphPanel: React.FC = ({ return (
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {graphTitle} - - -
- )} +
= ({ return (
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {knobTitle} - - -
- )} +
@@ -1856,47 +1798,19 @@ export const SingleKeyStatPanel: React.FC = ({
{/* 헤더 */}
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {keyLikeTitle} - - -
- )} +
{/* 탭 */} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index 1b5131135..b24221f24 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -60,9 +60,9 @@ import { import MoreVerticalIcon from '@components/main/Modal/content/pickers/MoreVerticalIcon'; import { usePickerItemMenu } from '@hooks/usePickerItemMenu'; import EditSessionBoundary from '../EditSessionBoundary'; -import { RenameIcon } from '../PanelIcons'; import SpritePoseEditorPopup from './SpritePoseEditorPopup'; import SpriteImageSettingsPopup from './SpriteImageSettingsPopup'; +import PanelRenameTitle from '../PanelRenameTitle'; // 계약과 동일한 cubic-bezier 문자열만 저장 (transitionEasing은 문자열 그대로 CSS로 간다) const SPRITE_EASING_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ @@ -788,47 +788,18 @@ export const SingleSpritePanel: React.FC = ({ return (
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={() => { - if (!renameCancelledRef.current) { - handleRenameCommit(renameValue); - } - renameCancelledRef.current = false; - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleRenameCancel(); - } - }} - /> - ) : ( -
- - {spriteTitle} - - -
- )} +
From 02966ea91f3e68d21a8a1d5ec952b3813c1e869d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:31:11 +0900 Subject: [PATCH 036/125] =?UTF-8?q?refactor:=20=EC=BA=94=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=83=81=ED=98=B8=EC=9E=91?= =?UTF-8?q?=EC=9A=A9=20=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=93=9C=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layers/GraphItem.trailingClick.test.tsx | 12 + .../components/main/Grid/layers/GraphItem.tsx | 158 +++---------- .../layers/KnobItem.trailingClick.test.tsx | 12 + .../components/main/Grid/layers/KnobItem.tsx | 151 +++--------- .../Grid/layers/SpriteItem.render.test.tsx | 12 + .../main/Grid/layers/SpriteItem.tsx | 146 +++--------- .../shared/Key.statePreview.test.tsx | 12 + .../shared/Key.trailingClick.test.tsx | 12 + src/renderer/components/shared/Key.tsx | 156 +++--------- .../hooks/Grid/useGridItemInteraction.ts | 222 ++++++++++++++++++ 10 files changed, 405 insertions(+), 488 deletions(-) create mode 100644 src/renderer/hooks/Grid/useGridItemInteraction.ts diff --git a/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx b/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx index 693c352c7..1c7ffdee4 100644 --- a/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx +++ b/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx @@ -34,6 +34,18 @@ vi.mock('@hooks/Grid', async (importOriginal) => { }; }); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: hookState.wasMoved, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); + vi.mock('@hooks/Grid/useSelectionDrag', () => ({ useSelectionDrag: () => ({ handlePointerDown: () => {}, diff --git a/src/renderer/components/main/Grid/layers/GraphItem.tsx b/src/renderer/components/main/Grid/layers/GraphItem.tsx index 9ebe47987..6070bdf78 100644 --- a/src/renderer/components/main/Grid/layers/GraphItem.tsx +++ b/src/renderer/components/main/Grid/layers/GraphItem.tsx @@ -1,10 +1,6 @@ import type { GradientSpec } from '@src/types/color'; import React, { useState } from 'react'; -import { isMac } from '@utils/core/platform'; -import { useDraggable, useSmartGuidesElements } from '@hooks/Grid'; -import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; -import { useSettingsStore } from '@stores/useSettingsStore'; -import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { useGridItemInteraction } from '@hooks/Grid/useGridItemInteraction'; import { useGradientPreviewSession } from '@stores/grid/useGradientEditStore'; import GraphPanel from '@components/shared/GraphPanel'; import { resolveImageSource } from '@utils/core/imageSource'; @@ -100,7 +96,6 @@ const GraphItem = ({ zIndex = 0, isViewportTransforming = false, }: GraphItemProps) => { - const macOS = isMac(); const { dx = 0, dy = 0, @@ -122,17 +117,6 @@ const GraphItem = ({ useInlineStyles = false, } = position ?? ({} as Partial); - const { getOtherElements } = useSmartGuidesElements(); - const gridSnapSize = useSettingsStore( - (state: { gridSettings?: { gridSnapSize?: number } }) => - state.gridSettings?.gridSnapSize ?? 5, - ); - const isDraggingOrResizing = useGridSelectionStore( - (state: { isDraggingOrResizing: boolean }) => state.isDraggingOrResizing, - ); - - const isSelectionMode = isSelected; - // 편집 세션 일시 페인트 — 저장·히스토리를 거치지 않는 드래그 프리뷰 const previewSession = useGradientPreviewSession( 'graph', @@ -146,7 +130,6 @@ const GraphItem = ({ const [uid] = useState( () => `graph-preview-${Math.random().toString(36).slice(2, 11)}`, ); - const effectiveElementId = elementId; const previewHistory = [...PREVIEW_HISTORY_BASE]; const previewImageSrc = @@ -155,117 +138,44 @@ const GraphItem = ({ null; const previewImageFit = idleImageFit || imageFit || 'cover'; - const draggable = useDraggable({ - gridSize: gridSnapSize, - initialX: dx, - initialY: dy, - onPositionChange: (newDx: number, newDy: number) => { - if (!isSelectionMode) { - // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 - onPositionChange(index, newDx, newDy, elementId); - } - }, + const { + isSelectionMode, + isDraggingOrResizing, + draggable, + handleSelectionDragPointerDown, + handleClick, + handleDoubleClick, + handleContextMenu, + attachRef, + } = useGridItemInteraction({ + index, + elementId, + dx, + dy, + elementWidth: width || 200, + elementHeight: height || 100, + isSelected, + selectedElements, zoom, panX, panY, - elementId: effectiveElementId, - elementWidth: width || 200, - elementHeight: height || 100, - getOtherElements, - disabled: isSelectionMode, + activeTool, + isViewportTransforming, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, + onMultiDrag, + onMultiDragStart, + onMultiDragEnd, + onEraserClick, + onContextMenu, + setReferenceRef, }); - const { handlePointerDown, movedDuringPressRef, pressMovedRef } = - useSelectionDrag({ - enabled: isSelectionMode, - zoom, - startX: dx, - startY: dy, - elementId: effectiveElementId, - elementWidth: width || 200, - elementHeight: height || 100, - selectedElements, - getOtherElements, - onMultiDragStart, - onMultiDrag, - onMultiDragEnd, - }); - if (position?.hidden) return null; - const handleClick = (e: React.MouseEvent) => { - // macOS ctrl+클릭은 우클릭 제스처 — Chromium이 contextmenu 뒤에 click도 발화하므로 - // 이 클릭이 선택·패널 오픈으로 이어져 방금 연 메뉴를 닫는 것을 차단 - if (macOS && e.ctrlKey) return; - // 드래그로 끝난 press의 trailing click은 클릭이 아니다 - 수식키 토글· - // 범위 선택·지우개로 새지 않게 흡수. 개별 드래그는 wasMoved, - // 선택 모드 다중 드래그는 pressMovedRef가 판별 (선택 모드에서는 - // 개별 draggable이 disabled라 wasMoved가 항상 false) - if (draggable.wasMoved || pressMovedRef.current) { - e.stopPropagation(); - return; - } - const isPrimaryModifierPressed = macOS ? e.metaKey : e.ctrlKey; - const isShiftPressed = e.shiftKey; - - if (isSelectionMode && isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - - if (isSelectionMode) { - e.stopPropagation(); - return; - } - - if (activeTool === 'eraser') { - onEraserClick?.(); - return; - } - - if (!draggable.wasMoved) { - if (isShiftPressed && onShiftClick) { - e.stopPropagation(); - onShiftClick(e); - return; - } - if (isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - onClick?.(e); - } - }; - - // 더블클릭 편집 진입 — 순수 더블클릭만 통과 (드래그·수식키·지우개·뷰포트 변환 제외) - const handleDoubleClick = (e: React.MouseEvent) => { - if (!onDoubleClick) return; - if (macOS && e.ctrlKey) return; - if (e.shiftKey || e.metaKey || e.ctrlKey) return; - if (activeTool === 'eraser') return; - if (isViewportTransforming) return; - if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) - return; - e.stopPropagation(); - onDoubleClick(e); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e); - }; - - const attachRef = (node: HTMLElement | null) => { - if (!isSelectionMode) { - draggable.ref(node); - } - if (typeof setReferenceRef === 'function') { - setReferenceRef(node); - } - }; return ( e.preventDefault()} diff --git a/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx b/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx index 38301450a..50466996d 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx @@ -34,6 +34,18 @@ vi.mock('@hooks/Grid', async (importOriginal) => { }; }); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: hookState.wasMoved, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); + vi.mock('@hooks/Grid/useSelectionDrag', () => ({ useSelectionDrag: () => ({ handlePointerDown: () => {}, diff --git a/src/renderer/components/main/Grid/layers/KnobItem.tsx b/src/renderer/components/main/Grid/layers/KnobItem.tsx index bb8684550..5cb56f869 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.tsx @@ -5,11 +5,7 @@ import { resolveStatePair, type GradientSpec, } from '@src/types/color'; -import { isMac } from '@utils/core/platform'; -import { useDraggable, useSmartGuidesElements } from '@hooks/Grid'; -import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; -import { useSettingsStore } from '@stores/useSettingsStore'; -import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { useGridItemInteraction } from '@hooks/Grid/useGridItemInteraction'; import { useGradientPreviewSession } from '@stores/grid/useGradientEditStore'; import { useEditStatePreviewActive } from '@stores/grid/useEditStatePreviewStore'; import { resolveImageSource } from '@utils/core/imageSource'; @@ -118,7 +114,6 @@ const KnobItem = ({ zIndex = 0, isViewportTransforming = false, }: KnobItemProps) => { - const macOS = isMac(); const { dx = 0, dy = 0, @@ -211,17 +206,6 @@ const KnobItem = ({ !showBorderRing && gradientRingWidth > 0 ? `${gradientRingWidth}px solid ${resolvedKnobBorder.color}` : 'none'; - const { getOtherElements } = useSmartGuidesElements(); - const gridSnapSize = useSettingsStore( - (state: { gridSettings?: { gridSnapSize?: number } }) => - state.gridSettings?.gridSnapSize ?? 5, - ); - const isDraggingOrResizing = useGridSelectionStore( - (state: { isDraggingOrResizing: boolean }) => state.isDraggingOrResizing, - ); - - const isSelectionMode = isSelected; - const effectiveElementId = elementId; const inactiveImageSrc = resolveImageSource(inactiveImage); const activeImageSrc = resolveImageSource(activeImage); @@ -251,121 +235,44 @@ const KnobItem = ({ : idleImageFit || imageFit || 'cover' ) as React.CSSProperties['objectFit']; - const draggable = useDraggable({ - gridSize: gridSnapSize, - initialX: dx, - initialY: dy, - onPositionChange: (newDx: number, newDy: number) => { - if (!isSelectionMode) { - // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 - onPositionChange(index, newDx, newDy, elementId); - } - }, - zoom, - panX, - panY, - elementId: effectiveElementId, - elementWidth: width || 60, - elementHeight: height || 60, - getOtherElements, - disabled: isSelectionMode, - }); - const { - handlePointerDown: handleSelectionDragPointerDown, - movedDuringPressRef, - pressMovedRef, - } = useSelectionDrag({ - enabled: isSelectionMode, - zoom, - startX: dx, - startY: dy, - elementId: effectiveElementId, + isSelectionMode, + isDraggingOrResizing, + draggable, + handleSelectionDragPointerDown, + handleClick, + handleDoubleClick, + handleContextMenu, + attachRef, + } = useGridItemInteraction({ + index, + elementId, + dx, + dy, elementWidth: width || 60, elementHeight: height || 60, + isSelected, selectedElements, - getOtherElements, - onMultiDragStart, + zoom, + panX, + panY, + activeTool, + isViewportTransforming, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, onMultiDrag, + onMultiDragStart, onMultiDragEnd, + onEraserClick, + onContextMenu, + setReferenceRef, }); if (position?.hidden) return null; - const handleClick = (e: React.MouseEvent) => { - // macOS ctrl+클릭은 우클릭 제스처 — Chromium이 contextmenu 뒤에 click도 발화하므로 - // 이 클릭이 선택·패널 오픈으로 이어져 방금 연 메뉴를 닫는 것을 차단 - if (macOS && e.ctrlKey) return; - // 드래그로 끝난 press의 trailing click은 클릭이 아니다 - 수식키 토글· - // 범위 선택·지우개로 새지 않게 흡수. 개별 드래그는 wasMoved, - // 선택 모드 다중 드래그는 pressMovedRef가 판별 (선택 모드에서는 - // 개별 draggable이 disabled라 wasMoved가 항상 false) - if (draggable.wasMoved || pressMovedRef.current) { - e.stopPropagation(); - return; - } - const isPrimaryModifierPressed = macOS ? e.metaKey : e.ctrlKey; - const isShiftPressed = e.shiftKey; - - if (isSelectionMode && isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - - if (isSelectionMode) { - e.stopPropagation(); - return; - } - - if (activeTool === 'eraser') { - onEraserClick?.(); - return; - } - - if (!draggable.wasMoved) { - if (isShiftPressed && onShiftClick) { - e.stopPropagation(); - onShiftClick(e); - return; - } - if (isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - onClick?.(e); - } - }; - - // 더블클릭 편집 진입 — 순수 더블클릭만 통과 (드래그·수식키·지우개·뷰포트 변환 제외) - const handleDoubleClick = (e: React.MouseEvent) => { - if (!onDoubleClick) return; - if (macOS && e.ctrlKey) return; - if (e.shiftKey || e.metaKey || e.ctrlKey) return; - if (activeTool === 'eraser') return; - if (isViewportTransforming) return; - if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) - return; - e.stopPropagation(); - onDoubleClick(e); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e); - }; - - const attachRef = (node: HTMLElement | null) => { - if (!isSelectionMode) { - draggable.ref(node); - } - if (typeof setReferenceRef === 'function') { - setReferenceRef(node); - } - }; - const transform = `translate(calc(${draggable.dx}px + var(--key-offset-x, 0px)), calc(${draggable.dy}px + var(--key-offset-y, 0px)))`; return ( diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index 9434d3158..9d6cf61bb 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -32,6 +32,18 @@ vi.mock('@hooks/Grid', async (importOriginal) => { }; }); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: false, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); + vi.mock('@hooks/Grid/useSelectionDrag', () => ({ useSelectionDrag: () => ({ handlePointerDown: () => {}, diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index c5b445779..411f60c01 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -1,9 +1,5 @@ import React from 'react'; -import { isMac } from '@utils/core/platform'; -import { useDraggable, useSmartGuidesElements } from '@hooks/Grid'; -import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; -import { useSettingsStore } from '@stores/useSettingsStore'; -import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { useGridItemInteraction } from '@hooks/Grid/useGridItemInteraction'; import { useSpriteEditPreview } from '@stores/grid/useSpriteEditPreviewStore'; import { resolveImageSource } from '@utils/core/imageSource'; import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; @@ -88,7 +84,6 @@ const SpriteItem = ({ zIndex = 0, isViewportTransforming = false, }: SpriteItemProps) => { - const macOS = isMac(); const { dx = 0, dy = 0, @@ -97,17 +92,6 @@ const SpriteItem = ({ className, } = position; - const { getOtherElements } = useSmartGuidesElements(); - const gridSnapSize = useSettingsStore( - (state: { gridSettings?: { gridSnapSize?: number } }) => - state.gridSettings?.gridSnapSize ?? 5, - ); - const isDraggingOrResizing = useGridSelectionStore( - (state: { isDraggingOrResizing: boolean }) => state.isDraggingOrResizing, - ); - - const isSelectionMode = isSelected; - // 편집 중 프리뷰 - 자세 팝업이면 그 자세를 그린다. 유효 자세는 composed poses가 // 최신(스크럽 프리뷰 병합)이라 우선하고, 무효 draft(preferFallback)나 canonical에 // 없는 신규 자세는 발행된 스냅샷으로 그린다. transform과 이미지는 같은 자세에서 @@ -127,118 +111,44 @@ const SpriteItem = ({ previewPose?.imageOverride ?? position.baseImage, ); - const draggable = useDraggable({ - gridSize: gridSnapSize, - initialX: dx, - initialY: dy, - onPositionChange: (newDx: number, newDy: number) => { - if (!isSelectionMode) { - // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 - onPositionChange(index, newDx, newDy, elementId); - } - }, - zoom, - panX, - panY, - elementId, - elementWidth: width || DEFAULT_SPRITE_SIZE, - elementHeight: height || DEFAULT_SPRITE_SIZE, - getOtherElements, - disabled: isSelectionMode, - }); - const { - handlePointerDown: handleSelectionDragPointerDown, - movedDuringPressRef, - pressMovedRef, - } = useSelectionDrag({ - enabled: isSelectionMode, - zoom, - startX: dx, - startY: dy, + isSelectionMode, + isDraggingOrResizing, + draggable, + handleSelectionDragPointerDown, + handleClick, + handleDoubleClick, + handleContextMenu, + attachRef, + } = useGridItemInteraction({ + index, elementId, + dx, + dy, elementWidth: width || DEFAULT_SPRITE_SIZE, elementHeight: height || DEFAULT_SPRITE_SIZE, + isSelected, selectedElements, - getOtherElements, - onMultiDragStart, + zoom, + panX, + panY, + activeTool, + isViewportTransforming, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, onMultiDrag, + onMultiDragStart, onMultiDragEnd, + onEraserClick, + onContextMenu, + setReferenceRef, }); if (position.hidden) return null; - const handleClick = (e: React.MouseEvent) => { - // macOS ctrl+클릭은 우클릭 제스처 - contextmenu 뒤에 오는 click이 - // 선택·패널 오픈으로 이어져 방금 연 메뉴를 닫는 것을 차단 - if (macOS && e.ctrlKey) return; - // 드래그로 끝난 press의 trailing click 흡수 (KnobItem과 동일 규칙) - if (draggable.wasMoved || pressMovedRef.current) { - e.stopPropagation(); - return; - } - const isPrimaryModifierPressed = macOS ? e.metaKey : e.ctrlKey; - const isShiftPressed = e.shiftKey; - - if (isSelectionMode && isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - - if (isSelectionMode) { - e.stopPropagation(); - return; - } - - if (activeTool === 'eraser') { - onEraserClick?.(); - return; - } - - if (!draggable.wasMoved) { - if (isShiftPressed && onShiftClick) { - e.stopPropagation(); - onShiftClick(e); - return; - } - if (isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - onClick?.(e); - } - }; - - // 더블클릭 편집 진입 - 순수 더블클릭만 통과 - const handleDoubleClick = (e: React.MouseEvent) => { - if (!onDoubleClick) return; - if (macOS && e.ctrlKey) return; - if (e.shiftKey || e.metaKey || e.ctrlKey) return; - if (activeTool === 'eraser') return; - if (isViewportTransforming) return; - if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) - return; - e.stopPropagation(); - onDoubleClick(e); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e); - }; - - const attachRef = (node: HTMLElement | null) => { - if (!isSelectionMode) { - draggable.ref(node); - } - if (typeof setReferenceRef === 'function') { - setReferenceRef(node); - } - }; - const transform = `translate(calc(${draggable.dx}px + var(--key-offset-x, 0px)), calc(${draggable.dy}px + var(--key-offset-y, 0px)))`; return ( diff --git a/src/renderer/components/shared/Key.statePreview.test.tsx b/src/renderer/components/shared/Key.statePreview.test.tsx index 2c8adc72f..cb31fd624 100644 --- a/src/renderer/components/shared/Key.statePreview.test.tsx +++ b/src/renderer/components/shared/Key.statePreview.test.tsx @@ -33,6 +33,18 @@ vi.mock('@hooks/Grid', async (importOriginal) => { }; }); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: false, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); + vi.mock('@hooks/Grid/useSelectionDrag', () => ({ useSelectionDrag: () => ({ handlePointerDown: () => {}, diff --git a/src/renderer/components/shared/Key.trailingClick.test.tsx b/src/renderer/components/shared/Key.trailingClick.test.tsx index fe675bbf6..31b2520ca 100644 --- a/src/renderer/components/shared/Key.trailingClick.test.tsx +++ b/src/renderer/components/shared/Key.trailingClick.test.tsx @@ -36,6 +36,18 @@ vi.mock('@hooks/Grid', async (importOriginal) => { }; }); +// 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 +vi.mock('@hooks/Grid/useDraggable', () => ({ + useDraggable: () => ({ + ref: () => {}, + dx: 0, + dy: 0, + wasMoved: hookState.wasMoved, + isDragging: false, + recentPressMovedRef: { current: false }, + }), +})); + vi.mock('@hooks/Grid/useSelectionDrag', () => ({ useSelectionDrag: () => ({ handlePointerDown: () => {}, diff --git a/src/renderer/components/shared/Key.tsx b/src/renderer/components/shared/Key.tsx index d12140526..b2c1c0a32 100644 --- a/src/renderer/components/shared/Key.tsx +++ b/src/renderer/components/shared/Key.tsx @@ -3,18 +3,13 @@ import React, { useEffect, useRef } from 'react'; import { getKeySignal } from '@stores/signals/keySignals'; import { getKeyCounterSignal } from '@stores/signals/keyCounterSignals'; import { useSignals } from '@preact/signals-react/runtime'; -import { isMac } from '@utils/core/platform'; -import { useDraggable } from '@hooks/Grid'; -import { useSelectionDrag } from '@hooks/Grid/useSelectionDrag'; +import { useGridItemInteraction } from '@hooks/Grid/useGridItemInteraction'; import type { KeyCounterSettings } from '@src/types/key/keys'; import { useCounterSettings } from '@hooks/overlay/useCounterSettings'; import { isErrorForCurrentSrc, useFailedImageSrcs, } from '@hooks/overlay/useFailedImageSrcs'; -import { useSmartGuidesElements } from '@hooks/Grid'; -import { useSettingsStore } from '@stores/useSettingsStore'; -import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import { warmupImageSource } from '@utils/core/imageWarmup'; import { computeKeyElementStyles, @@ -113,7 +108,6 @@ const DraggableKey = React.memo( }: DraggableKeyProps) => { useSignals(); - const macOS = isMac(); // keyName은 호출부에서 합성이 끝난 표시 라벨 const displayName = keyName; const { dx, dy, width, height = 60, className, counter } = position; @@ -125,18 +119,6 @@ const DraggableKey = React.memo( counterSettings.enabled && counterSettings.placement === 'inside'; - const { getOtherElements } = useSmartGuidesElements(); - - const gridSnapSize = useSettingsStore( - (state) => state.gridSettings?.gridSnapSize ?? 5, - ); - - const isDraggingOrResizing = useGridSelectionStore( - (state) => state.isDraggingOrResizing, - ); - - const effectiveElementId = elementId; - // 편집 세션 일시 페인트 — 드래그 프리뷰가 저장·히스토리를 거치지 않고 // 해당 표면의 spec과 대기/입력 상태 전체를 함께 그린다 const previewAnchorKind = anchorKind ?? 'key'; @@ -174,116 +156,42 @@ const DraggableKey = React.memo( anchorOrigin, ); - const isSelectionMode = isSelected; - - const draggable = useDraggable({ - gridSize: gridSnapSize, - initialX: dx, - initialY: dy, - onPositionChange: (newDx: number, newDy: number) => { - if (!isSelectionMode) { - // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 - onPositionChange(index, newDx, newDy, elementId); - } - }, - zoom, - panX, - panY, - elementId: effectiveElementId, - elementWidth: width || 60, - elementHeight: height || 60, - getOtherElements, - disabled: isSelectionMode, - }); - const { - handlePointerDown: handleSelectionDragPointerDown, - movedDuringPressRef, - pressMovedRef, - } = useSelectionDrag({ - enabled: isSelectionMode, - zoom, - startX: dx, - startY: dy, - elementId: effectiveElementId, + isSelectionMode, + isDraggingOrResizing, + draggable, + handleSelectionDragPointerDown, + handleClick, + handleDoubleClick, + handleContextMenu, + attachRef: attachInteractionRef, + } = useGridItemInteraction({ + index, + elementId, + dx, + dy, elementWidth: width || 60, elementHeight: height || 60, + isSelected, selectedElements, - getOtherElements, - onMultiDragStart, + zoom, + panX, + panY, + activeTool, + isViewportTransforming, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, onMultiDrag, + onMultiDragStart, onMultiDragEnd, + onEraserClick, + onContextMenu, + setReferenceRef, }); - const handleClick = (e: React.MouseEvent) => { - // macOS ctrl+클릭은 우클릭 제스처 — Chromium이 contextmenu 뒤에 click도 발화하므로 - // 이 클릭이 선택·패널 오픈으로 이어져 방금 연 메뉴를 닫는 것을 차단 - if (macOS && e.ctrlKey) return; - // 드래그로 끝난 press의 trailing click은 클릭이 아니다 - 수식키 토글· - // 범위 선택·지우개로 새지 않게 흡수. 개별 드래그는 wasMoved, - // 선택 모드 다중 드래그는 pressMovedRef가 판별 (선택 모드에서는 - // 개별 draggable이 disabled라 wasMoved가 항상 false) - if (draggable.wasMoved || pressMovedRef.current) { - e.stopPropagation(); - return; - } - const isPrimaryModifierPressed = macOS ? e.metaKey : e.ctrlKey; - const isShiftPressed = e.shiftKey; - - if (isSelectionMode && isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - - if (isSelectionMode) { - e.stopPropagation(); - return; - } - - if (activeTool === 'eraser') { - onEraserClick?.(); - return; - } - - if (!draggable.wasMoved) { - if (isShiftPressed && onShiftClick) { - e.stopPropagation(); - onShiftClick(e); - return; - } - if (isPrimaryModifierPressed && onCtrlClick) { - e.stopPropagation(); - onCtrlClick(e); - return; - } - if (onClick) { - onClick(e); - } - } - }; - - // 더블클릭 편집 진입 — 순수 더블클릭만 통과. - // 두 번째 press가 다중 드래그로 이어진 경우(movedDuringPressRef)와 - // 단일 드래그(wasMoved), 수식키·지우개·뷰포트 변환 중은 제외 - const handleDoubleClick = (e: React.MouseEvent) => { - if (!onDoubleClick) return; - if (macOS && e.ctrlKey) return; - if (e.shiftKey || e.metaKey || e.ctrlKey) return; - if (activeTool === 'eraser') return; - if (isViewportTransforming) return; - if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) - return; - e.stopPropagation(); - onDoubleClick(e); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu?.(e); - }; - const renderDx = draggable.dx; const renderDy = draggable.dy; @@ -378,12 +286,10 @@ const DraggableKey = React.memo( ); }; + // 카운터 축 앵커가 루트 노드를 읽으므로 공용 부착 앞에 먼저 채운다 const attachRef = (node: HTMLElement | null) => { keyRootRef.current = node; - if (!isSelectionMode) { - draggable.ref(node); - } - if (typeof setReferenceRef === 'function') setReferenceRef(node); + attachInteractionRef(node); }; return ( diff --git a/src/renderer/hooks/Grid/useGridItemInteraction.ts b/src/renderer/hooks/Grid/useGridItemInteraction.ts new file mode 100644 index 000000000..cf16a7595 --- /dev/null +++ b/src/renderer/hooks/Grid/useGridItemInteraction.ts @@ -0,0 +1,222 @@ +import type React from 'react'; + +import { isMac } from '@utils/core/platform'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { useSettingsStore } from '@stores/useSettingsStore'; + +import { useDraggable } from './useDraggable'; +import { useSelectionDrag } from './useSelectionDrag'; +import { useSmartGuidesElements } from './useSmartGuidesElements'; + +/** + * 캔버스 아이템의 공통 상호작용 골격 - 개별 드래그, 선택 모드 다중 드래그, + * 클릭·더블클릭·컨텍스트 메뉴 라우팅, 루트 ref 부착. + * 키·그래프·노브·스프라이트 잎이 같은 계약을 공유한다. + * 기본 크기 폴백처럼 요소별로 다른 값은 호출부가 이미 풀어서 넘긴다 + */ + +export interface GridItemSelectedElement { + id: string; + type?: string; + index?: number; +} + +export interface GridItemInteractionOptions { + index: number; + elementId: string; + dx: number; + dy: number; + /** 폴백까지 적용된 실제 크기 */ + elementWidth: number; + elementHeight: number; + isSelected: boolean; + selectedElements: GridItemSelectedElement[]; + zoom: number; + panX: number; + panY: number; + activeTool?: string; + isViewportTransforming: boolean; + onPositionChange: ( + index: number, + dx: number, + dy: number, + elementId: string, + ) => void; + onClick?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; + onCtrlClick?: (e: React.MouseEvent) => void; + onShiftClick?: (e: React.MouseEvent) => void; + onMultiDrag?: (dx: number, dy: number) => void; + onMultiDragStart?: () => void | (() => void); + onMultiDragEnd?: () => void; + onEraserClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; + setReferenceRef?: (node: HTMLElement | null) => void; +} + +export const useGridItemInteraction = ({ + index, + elementId, + dx, + dy, + elementWidth, + elementHeight, + isSelected, + selectedElements, + zoom, + panX, + panY, + activeTool, + isViewportTransforming, + onPositionChange, + onClick, + onDoubleClick, + onCtrlClick, + onShiftClick, + onMultiDrag, + onMultiDragStart, + onMultiDragEnd, + onEraserClick, + onContextMenu, + setReferenceRef, +}: GridItemInteractionOptions) => { + const macOS = isMac(); + const { getOtherElements } = useSmartGuidesElements(); + const gridSnapSize = useSettingsStore( + (state: { gridSettings?: { gridSnapSize?: number } }) => + state.gridSettings?.gridSnapSize ?? 5, + ); + const isDraggingOrResizing = useGridSelectionStore( + (state: { isDraggingOrResizing: boolean }) => state.isDraggingOrResizing, + ); + + const isSelectionMode = isSelected; + + const draggable = useDraggable({ + gridSize: gridSnapSize, + initialX: dx, + initialY: dy, + onPositionChange: (newDx: number, newDy: number) => { + if (!isSelectionMode) { + // 프리즈된 index의 재해석은 수신 측이 elementId로 수행 + onPositionChange(index, newDx, newDy, elementId); + } + }, + zoom, + panX, + panY, + elementId, + elementWidth, + elementHeight, + getOtherElements, + disabled: isSelectionMode, + }); + + const { + handlePointerDown: handleSelectionDragPointerDown, + movedDuringPressRef, + pressMovedRef, + } = useSelectionDrag({ + enabled: isSelectionMode, + zoom, + startX: dx, + startY: dy, + elementId, + elementWidth, + elementHeight, + selectedElements, + getOtherElements, + onMultiDragStart, + onMultiDrag, + onMultiDragEnd, + }); + + const handleClick = (e: React.MouseEvent) => { + // macOS ctrl+클릭은 우클릭 제스처 — Chromium이 contextmenu 뒤에 click도 발화하므로 + // 이 클릭이 선택·패널 오픈으로 이어져 방금 연 메뉴를 닫는 것을 차단 + if (macOS && e.ctrlKey) return; + // 드래그로 끝난 press의 trailing click은 클릭이 아니다 - 수식키 토글· + // 범위 선택·지우개로 새지 않게 흡수. 개별 드래그는 wasMoved, + // 선택 모드 다중 드래그는 pressMovedRef가 판별 (선택 모드에서는 + // 개별 draggable이 disabled라 wasMoved가 항상 false) + if (draggable.wasMoved || pressMovedRef.current) { + e.stopPropagation(); + return; + } + const isPrimaryModifierPressed = macOS ? e.metaKey : e.ctrlKey; + const isShiftPressed = e.shiftKey; + + if (isSelectionMode && isPrimaryModifierPressed && onCtrlClick) { + e.stopPropagation(); + onCtrlClick(e); + return; + } + + if (isSelectionMode) { + e.stopPropagation(); + return; + } + + if (activeTool === 'eraser') { + onEraserClick?.(); + return; + } + + if (!draggable.wasMoved) { + if (isShiftPressed && onShiftClick) { + e.stopPropagation(); + onShiftClick(e); + return; + } + if (isPrimaryModifierPressed && onCtrlClick) { + e.stopPropagation(); + onCtrlClick(e); + return; + } + onClick?.(e); + } + }; + + // 더블클릭 편집 진입 — 순수 더블클릭만 통과. + // 두 번째 press가 다중 드래그로 이어진 경우(movedDuringPressRef)와 + // 단일 드래그(wasMoved), 수식키·지우개·뷰포트 변환 중은 제외 + const handleDoubleClick = (e: React.MouseEvent) => { + if (!onDoubleClick) return; + if (macOS && e.ctrlKey) return; + if (e.shiftKey || e.metaKey || e.ctrlKey) return; + if (activeTool === 'eraser') return; + if (isViewportTransforming) return; + if (draggable.recentPressMovedRef.current || movedDuringPressRef.current) + return; + e.stopPropagation(); + onDoubleClick(e); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu?.(e); + }; + + const attachRef = (node: HTMLElement | null) => { + if (!isSelectionMode) { + draggable.ref(node); + } + if (typeof setReferenceRef === 'function') { + setReferenceRef(node); + } + }; + + return { + isSelectionMode, + isDraggingOrResizing, + draggable, + handleSelectionDragPointerDown, + movedDuringPressRef, + pressMovedRef, + handleClick, + handleDoubleClick, + handleContextMenu, + attachRef, + }; +}; From 9f37e8516d75efa3eb876b1ee6793c1b3b383bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:32:59 +0900 Subject: [PATCH 037/125] =?UTF-8?q?refactor:=20bounds=20op=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EA=B3=A8=EA=B2=A9=EC=9D=84=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/editor_ops.rs | 222 +++++++++++++++++++++++------- 1 file changed, 170 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/state/editor_ops.rs b/src-tauri/src/state/editor_ops.rs index 7ed12622b..83fa95868 100644 --- a/src-tauri/src/state/editor_ops.rs +++ b/src-tauri/src/state/editor_ops.rs @@ -1678,6 +1678,48 @@ fn apply_sprite_resize(sprite: &mut ReactiveSpritePosition, bounds: &EditorBound } } +fn apply_bounds_op( + candidate: &mut EditorDocumentV1, + locations: &HashMap, + op_index: usize, + id: &str, + bounds: EditorBoundsV1, + mutate: F, +) -> Result +where + F: FnOnce( + &mut EditorDocumentV1, + &ElementLocation, + &EditorBoundsV1, + ) -> Result<(), EditorCommitError>, +{ + let Some(location) = locations.get(id) else { + validate_editor_op_bounds(op_index, None, bounds)?; + return Ok(EditorOpResultV1 { + status: EditorOpResultStatusV1::TargetMissing, + bounds: None, + }); + }; + + let current_bounds = element_bounds(candidate, location).ok_or_else(|| { + EditorCommitError::validation( + "ELEMENT_LOCATOR_INVALID", + "native element locator no longer matches the editor document", + ) + })?; + validate_editor_op_bounds(op_index, Some(current_bounds), bounds)?; + let status = if current_bounds == bounds { + EditorOpResultStatusV1::NoChange + } else { + mutate(candidate, location, &bounds)?; + EditorOpResultStatusV1::Applied + }; + Ok(EditorOpResultV1 { + status, + bounds: element_bounds(candidate, location), + }) +} + fn delete_elements( document: &mut EditorDocumentV1, delete_ids: &HashMap>, @@ -2244,60 +2286,30 @@ pub(crate) fn prepare_editor_ops_transition_with_plugin_refs( for (op_index, op) in ops.iter().enumerate() { match op { EditorOpV1::SetBounds { id, bounds, .. } => { - let Some(location) = locations.get(id) else { - validate_editor_op_bounds(op_index, None, *bounds)?; - op_results.push(EditorOpResultV1 { - status: EditorOpResultStatusV1::TargetMissing, - bounds: None, - }); - continue; - }; - - let current_bounds = element_bounds(&candidate, location).ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "native element locator no longer matches the editor document", - ) - })?; - validate_editor_op_bounds(op_index, Some(current_bounds), *bounds)?; - let status = if current_bounds == *bounds { - EditorOpResultStatusV1::NoChange - } else { - apply_bounds(element_common_at_mut(&mut candidate, location)?, bounds); - EditorOpResultStatusV1::Applied - }; - op_results.push(EditorOpResultV1 { - status, - bounds: element_bounds(&candidate, location), - }); + op_results.push(apply_bounds_op( + &mut candidate, + &locations, + op_index, + id, + *bounds, + |candidate, location, bounds| { + apply_bounds(element_common_at_mut(candidate, location)?, bounds); + Ok(()) + }, + )?); } EditorOpV1::ResizeSprite { id, bounds } => { - let Some(location) = locations.get(id) else { - validate_editor_op_bounds(op_index, None, *bounds)?; - op_results.push(EditorOpResultV1 { - status: EditorOpResultStatusV1::TargetMissing, - bounds: None, - }); - continue; - }; - - let current_bounds = element_bounds(&candidate, location).ok_or_else(|| { - EditorCommitError::validation( - "ELEMENT_LOCATOR_INVALID", - "native element locator no longer matches the editor document", - ) - })?; - validate_editor_op_bounds(op_index, Some(current_bounds), *bounds)?; - let status = if current_bounds == *bounds { - EditorOpResultStatusV1::NoChange - } else { - apply_sprite_resize(sprite_at_mut(&mut candidate, location)?, bounds); - EditorOpResultStatusV1::Applied - }; - op_results.push(EditorOpResultV1 { - status, - bounds: element_bounds(&candidate, location), - }); + op_results.push(apply_bounds_op( + &mut candidate, + &locations, + op_index, + id, + *bounds, + |candidate, location, bounds| { + apply_sprite_resize(sprite_at_mut(candidate, location)?, bounds); + Ok(()) + }, + )?); } EditorOpV1::DeleteElement { element_type, id } => { let Some(location) = locations.get(id) else { @@ -4362,6 +4374,112 @@ mod tests { ); } + #[test] + fn missing_bounds_targets_still_reject_invalid_bounds() { + let store = store_with_sprite(); + let missing_id = uuid::Uuid::new_v4().to_string(); + let invalid_bounds = EditorBoundsV1 { + dx: 20.0, + dy: 30.0, + width: 0.0, + height: 250.0, + }; + + for op in [ + EditorOpV1::SetBounds { + element_type: EditorElementTypeV1::Sprite, + id: missing_id.clone(), + bounds: invalid_bounds, + }, + resize_sprite_op(missing_id.clone(), invalid_bounds), + ] { + let error = prepare_editor_ops_transition(&store, &[op]).unwrap_err(); + assert_eq!(validation_code(&error), Some("DIMENSION_OUT_OF_RANGE")); + } + } + + #[test] + fn sequential_sprite_resizes_use_candidate_bounds_for_each_ratio() { + let mut store = store_with_sprite(); + let position = &mut store.sprite_positions.get_mut("4key").unwrap()[0]; + position.width = 200.0; + position.height = 100.0; + position.image_rect = SpriteRect { + x: 20.0, + y: -10.0, + width: 100.0, + height: 50.0, + }; + position.idle_transform = SpriteTransform { + x: 10.0, + y: -4.0, + ..SpriteTransform::default() + }; + position.poses = vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + transform: SpriteTransform { + x: -6.0, + y: 8.0, + ..SpriteTransform::default() + }, + ..SpritePose::default() + }]; + let id = position.id.clone(); + let first_bounds = EditorBoundsV1 { + dx: 10.0, + dy: 20.0, + width: 400.0, + height: 200.0, + }; + let second_bounds = EditorBoundsV1 { + dx: -10.0, + dy: -20.0, + width: 100.0, + height: 50.0, + }; + + let transition = prepare_editor_ops_transition( + &store, + &[ + resize_sprite_op(&id, first_bounds), + resize_sprite_op(id, second_bounds), + ], + ) + .unwrap(); + let resized = &transition.candidate.sprite_positions["4key"][0]; + + assert_eq!(sprite_bounds(resized), second_bounds); + assert_eq!( + resized.image_rect, + SpriteRect { + x: 10.0, + y: -5.0, + width: 50.0, + height: 25.0, + } + ); + assert_eq!( + (resized.idle_transform.x, resized.idle_transform.y), + (5.0, -2.0) + ); + assert_eq!( + (resized.poses[0].transform.x, resized.poses[0].transform.y,), + (-3.0, 4.0) + ); + assert_eq!( + transition + .op_results + .iter() + .map(|result| result.status) + .collect::>(), + [ + EditorOpResultStatusV1::Applied, + EditorOpResultStatusV1::Applied, + ] + ); + assert_eq!(transition.op_results[1].bounds, Some(second_bounds)); + } + #[test] fn resize_sprite_type_mismatch_rejects_the_whole_mixed_batch() { let store = base_store(); From 4d4f250106e46be2b386729c07be1e278001c2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:32:59 +0900 Subject: [PATCH 038/125] =?UTF-8?q?test:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=9D=98=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EB=8B=A4=EC=9D=8C=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?sweep=20=EB=B3=B4=ED=98=B8=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/store.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index af34c659b..57a8c4f13 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -22706,12 +22706,16 @@ mod tests { let stat_sound = dir.join("sounds").join("stat.wav"); let graph_sound = dir.join("sounds").join("graph.wav"); let knob_sound = dir.join("sounds").join("knob.wav"); + let sprite_base_image = dir.join("images").join("sprite-base.png"); + let sprite_pose_image = dir.join("images").join("sprite-pose.png"); for path in [ &font_path, &library_sound, &stat_sound, &graph_sound, &knob_sound, + &sprite_base_image, + &sprite_pose_image, ] { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, b"asset-fixture").unwrap(); @@ -22765,6 +22769,18 @@ mod tests { }) .unwrap(), ); + let sprite = serde_json::to_value(ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + base_image: Some(sprite_base_image.to_string_lossy().into_owned()), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + image_override: Some(sprite_pose_image.to_string_lossy().into_owned()), + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }) + .unwrap(); let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); let fields = fixture.as_object_mut().unwrap(); @@ -22779,6 +22795,10 @@ mod tests { json!({ "asset-mode": [graph] }), ); fields.insert("knobPositions".to_string(), json!({ "asset-mode": [knob] })); + fields.insert( + "spritePositions".to_string(), + json!({ "asset-mode": [sprite, { "width": "invalid" }] }), + ); std::fs::write( dir.join("store.json"), serde_json::to_vec_pretty(&fixture).unwrap(), @@ -22795,6 +22815,7 @@ mod tests { assert_eq!(snapshot.stat_positions["asset-mode"].len(), 1); assert_eq!(snapshot.graph_positions["asset-mode"].len(), 1); assert_eq!(snapshot.knob_positions["asset-mode"].len(), 1); + assert_eq!(snapshot.sprite_positions["asset-mode"].len(), 1); recovered.cleanup_orphan_assets_now().unwrap(); recovered.flush_and_shutdown().unwrap(); drop(recovered); @@ -22808,6 +22829,8 @@ mod tests { &stat_sound, &graph_sound, &knob_sound, + &sprite_base_image, + &sprite_pose_image, ] { assert!(path.exists(), "recovered asset was quarantined: {path:?}"); } From 0a24c002b01cf77062369d0c2391147fb9133d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:32:59 +0900 Subject: [PATCH 039/125] =?UTF-8?q?test:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=88=98=EC=B9=98=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EA=B3=BC=20=EB=B3=B5=EA=B5=AC=20=EB=B2=94=EC=9C=84=EC=9D=98=20?= =?UTF-8?q?=ED=91=9C=20=EC=9D=BC=EC=B9=98=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/migration.rs | 318 ++++++++++++++++++++++++++++++- 1 file changed, 313 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/state/migration.rs b/src-tauri/src/state/migration.rs index 911592ac3..e2513c805 100644 --- a/src-tauri/src/state/migration.rs +++ b/src-tauri/src/state/migration.rs @@ -2446,19 +2446,56 @@ mod tests { use crate::{ defaults::{default_keys, default_positions}, models::{ - AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, FontType, GraphPosition, - GraphStatType, GraphType, ImageTransform, KeyCounterAlign, KeyCounterAlignMode, - KeyCounterColor, KeyCounterPlacement, KeyMappings, KeyPosition, KeySlot, KnobPosition, - LayerGroupDef, NoteColor, OverlayBounds, ReactiveSpritePosition, SlotMatch, - SoundLibraryEntry, SpriteAnchor, SpritePose, SpritePositions, SpriteRect, + AppStoreData, CustomCssHistoryEntry, CustomFont, CustomTab, EditorDocumentV1, FontType, + GraphPosition, GraphStatType, GraphType, ImageTransform, KeyCounterAlign, + KeyCounterAlignMode, KeyCounterColor, KeyCounterPlacement, KeyMappings, KeyPosition, + KeySlot, KnobPosition, LayerGroupDef, NoteColor, OverlayBounds, ReactiveSpritePosition, + SlotMatch, SoundLibraryEntry, SpriteAnchor, SpritePose, SpritePositions, SpriteRect, SpriteTransform, StatPosition, StatType, StoredOverlayBounds, StoredOverlayNativePosition, TabCss, TabNoteSettings, POSITION_COLLECTION_FIELDS, }, + state::editor::validate_document_transition, }; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; + const SPRITE_CONSTRAINT_FIXTURE: &str = + include_str!("../../../tests/fixtures/sprite-constraint-parity.json"); + + #[derive(Clone, Copy, Deserialize)] + struct NumericRangeFixture { + min: f64, + max: f64, + } + + #[derive(Clone, Copy, Deserialize)] + struct IntegerRangeFixture { + min: u32, + max: u32, + } + + #[derive(Clone, Copy, Deserialize)] + #[serde(rename_all = "camelCase")] + struct ImageRectRangeFixture { + coord_min: f64, + coord_max: f64, + dimension_max: f64, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct SpriteNumericConstraintFixture { + offset: NumericRangeFixture, + rotation: NumericRangeFixture, + scale: NumericRangeFixture, + anchor: NumericRangeFixture, + transition_ms: IntegerRangeFixture, + image_rect: ImageRectRangeFixture, + resize_min_dimension: f64, + press_duration_ms: IntegerRangeFixture, + } + fn rehome_test_directory(label: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("dmnote-rehome-{label}-{}", uuid::Uuid::new_v4())) } @@ -5969,6 +6006,277 @@ mod tests { assert_eq!(loaded.data.font_settings.custom_fonts, vec![font]); } + fn sprite_for_numeric_constraint_parity() -> ReactiveSpritePosition { + ReactiveSpritePosition { + id: uuid::Uuid::new_v4().to_string(), + poses: vec![SpritePose { + pose_id: uuid::Uuid::new_v4().to_string(), + triggers: vec![uuid::Uuid::new_v4().to_string()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + } + } + + fn sprite_passes_editor_validation(sprite: ReactiveSpritePosition) -> bool { + let current_store = AppStoreData::default(); + let mut candidate_store = current_store.clone(); + candidate_store + .sprite_positions + .insert("4key".to_string(), vec![sprite]); + validate_document_transition( + &EditorDocumentV1::from_store(¤t_store), + &EditorDocumentV1::from_store(&candidate_store), + ¤t_store, + &candidate_store, + ) + .is_ok() + } + + fn repair_single_sprite_numeric_ranges( + sprite: ReactiveSpritePosition, + ) -> (bool, ReactiveSpritePosition) { + let mut data = AppStoreData::default(); + data.sprite_positions + .insert("4key".to_string(), vec![sprite]); + let repaired = repair_sprite_numeric_ranges(&mut data); + let sprite = data.sprite_positions.remove("4key").unwrap().remove(0); + (repaired, sprite) + } + + fn assert_valid_and_unrepaired(name: &str, sprite: ReactiveSpritePosition) { + assert!( + sprite_passes_editor_validation(sprite.clone()), + "validator rejected {name}" + ); + let original = sprite.clone(); + let (repaired, sprite) = repair_single_sprite_numeric_ranges(sprite); + assert!(!repaired, "repair changed valid {name}"); + assert_eq!(sprite, original, "repair mutated valid {name}"); + } + + fn assert_invalid_then_repaired(name: &str, sprite: ReactiveSpritePosition) { + assert!( + !sprite_passes_editor_validation(sprite.clone()), + "validator accepted invalid {name}" + ); + let (repaired, sprite) = repair_single_sprite_numeric_ranges(sprite); + assert!(repaired, "repair ignored invalid {name}"); + assert!( + sprite_passes_editor_validation(sprite), + "repair left {name} invalid" + ); + } + + #[test] + fn sprite_validator_and_repair_ranges_follow_shared_fixture_for_every_numeric_field() { + #[derive(Clone, Copy)] + struct BoundedFloatField { + name: &'static str, + minimum: f64, + maximum: f64, + outside_delta: f64, + set: fn(&mut ReactiveSpritePosition, f64), + } + + #[derive(Clone, Copy)] + struct PositiveFloatField { + name: &'static str, + maximum: f64, + set: fn(&mut ReactiveSpritePosition, f64), + } + + #[derive(Clone, Copy)] + struct IntegerField { + name: &'static str, + minimum: u32, + maximum: u32, + set: fn(&mut ReactiveSpritePosition, u32), + } + + let fixture: SpriteNumericConstraintFixture = + serde_json::from_str(SPRITE_CONSTRAINT_FIXTURE).unwrap(); + let bounded_fields: [BoundedFloatField; 14] = [ + BoundedFloatField { + name: "idleTransform.x", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.x = value, + }, + BoundedFloatField { + name: "idleTransform.y", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.y = value, + }, + BoundedFloatField { + name: "idleTransform.rotation", + minimum: fixture.rotation.min, + maximum: fixture.rotation.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.rotation = value, + }, + BoundedFloatField { + name: "idleTransform.scale", + minimum: fixture.scale.min, + maximum: fixture.scale.max, + outside_delta: 0.001, + set: |sprite, value| sprite.idle_transform.scale = value, + }, + BoundedFloatField { + name: "poses[0].transform.x", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.x = value, + }, + BoundedFloatField { + name: "poses[0].transform.y", + minimum: fixture.offset.min, + maximum: fixture.offset.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.y = value, + }, + BoundedFloatField { + name: "poses[0].transform.rotation", + minimum: fixture.rotation.min, + maximum: fixture.rotation.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.rotation = value, + }, + BoundedFloatField { + name: "poses[0].transform.scale", + minimum: fixture.scale.min, + maximum: fixture.scale.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].transform.scale = value, + }, + BoundedFloatField { + name: "pivot.x", + minimum: fixture.anchor.min, + maximum: fixture.anchor.max, + outside_delta: 0.001, + set: |sprite, value| sprite.pivot.x = value, + }, + BoundedFloatField { + name: "pivot.y", + minimum: fixture.anchor.min, + maximum: fixture.anchor.max, + outside_delta: 0.001, + set: |sprite, value| sprite.pivot.y = value, + }, + BoundedFloatField { + name: "poses[0].contactPoint.x", + minimum: fixture.anchor.min, + maximum: fixture.anchor.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].contact_point.x = value, + }, + BoundedFloatField { + name: "poses[0].contactPoint.y", + minimum: fixture.anchor.min, + maximum: fixture.anchor.max, + outside_delta: 0.001, + set: |sprite, value| sprite.poses[0].contact_point.y = value, + }, + BoundedFloatField { + name: "imageRect.x", + minimum: fixture.image_rect.coord_min, + maximum: fixture.image_rect.coord_max, + outside_delta: 1.0, + set: |sprite, value| sprite.image_rect.x = value, + }, + BoundedFloatField { + name: "imageRect.y", + minimum: fixture.image_rect.coord_min, + maximum: fixture.image_rect.coord_max, + outside_delta: 1.0, + set: |sprite, value| sprite.image_rect.y = value, + }, + ]; + let positive_fields: [PositiveFloatField; 2] = [ + PositiveFloatField { + name: "imageRect.width", + maximum: fixture.image_rect.dimension_max, + set: |sprite, value| sprite.image_rect.width = value, + }, + PositiveFloatField { + name: "imageRect.height", + maximum: fixture.image_rect.dimension_max, + set: |sprite, value| sprite.image_rect.height = value, + }, + ]; + let integer_fields: [IntegerField; 2] = [ + IntegerField { + name: "transitionMs", + minimum: fixture.transition_ms.min, + maximum: fixture.transition_ms.max, + set: |sprite, value| sprite.transition_ms = value, + }, + IntegerField { + name: "pressDurationMs", + minimum: fixture.press_duration_ms.min, + maximum: fixture.press_duration_ms.max, + set: |sprite, value| sprite.press_duration_ms = value, + }, + ]; + + for field in bounded_fields { + for (edge, value) in [("minimum", field.minimum), ("maximum", field.maximum)] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_valid_and_unrepaired(&format!("{} {edge}", field.name), sprite); + } + for (side, value) in [ + ("below minimum", field.minimum - field.outside_delta), + ("above maximum", field.maximum + field.outside_delta), + ("non-finite", f64::NAN), + ] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_invalid_then_repaired(&format!("{} {side}", field.name), sprite); + } + } + + for field in positive_fields { + for (edge, value) in [ + ("positive resize sentinel", fixture.resize_min_dimension), + ("maximum", field.maximum), + ] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_valid_and_unrepaired(&format!("{} {edge}", field.name), sprite); + } + for (side, value) in [ + ("not positive", 0.0), + ("above maximum", field.maximum + 1.0), + ("non-finite", f64::NAN), + ] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_invalid_then_repaired(&format!("{} {side}", field.name), sprite); + } + } + + for field in integer_fields { + for (edge, value) in [("minimum", field.minimum), ("maximum", field.maximum)] { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_valid_and_unrepaired(&format!("{} {edge}", field.name), sprite); + } + if let Some(value) = field.minimum.checked_sub(1) { + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, value); + assert_invalid_then_repaired(&format!("{} below minimum", field.name), sprite); + } + let mut sprite = sprite_for_numeric_constraint_parity(); + (field.set)(&mut sprite, field.maximum.checked_add(1).unwrap()); + assert_invalid_then_repaired(&format!("{} above maximum", field.name), sprite); + } + } + #[test] fn sprite_numeric_repair_is_component_wise_idempotent_and_preserves_collections() { let original_triggers = vec![ From 804b36c48f02a04b05325c64dacb4171453f0b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:35:25 +0900 Subject: [PATCH 040/125] =?UTF-8?q?refactor:=20=EB=B3=B5=EC=A0=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=8A=A4=ED=8A=B8=20=EA=BB=8D=EB=8D=B0=EA=B8=B0?= =?UTF-8?q?=EC=99=80=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=B3=B5=EC=9B=90=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 42 ++++++------------ .../main/Grid/layers/DuplicateGhostHost.tsx | 44 +++++++++++++++++++ .../main/Grid/layers/SpriteDuplicateGhost.tsx | 20 +++------ .../overlay/counters/OverlaySpriteItem.tsx | 40 +++++++---------- 4 files changed, 82 insertions(+), 64 deletions(-) create mode 100644 src/renderer/components/main/Grid/layers/DuplicateGhostHost.tsx diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index bc8e51392..939639d22 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -64,6 +64,7 @@ import GraphItem from '../layers/GraphItem'; import KnobItem from '../layers/KnobItem'; import SpriteItem from '../layers/SpriteItem'; import SpriteDuplicateGhost from '../layers/SpriteDuplicateGhost'; +import DuplicateGhostHost from '../layers/DuplicateGhostHost'; import { useGridSelectionStore, isElementInMarquee, @@ -1535,27 +1536,20 @@ const Grid = ({ } if (duplicateState.elementType === 'graph') { - const width = duplicateState.position?.width || 200; - const height = duplicateState.position?.height || 100; - const offsetX = duplicateCursor.x - width / 2; - const offsetY = duplicateCursor.y - height / 2; return ( -
{renderGhostBorderRing(false)} -
+ ); } @@ -1601,26 +1595,18 @@ const Grid = ({ // keyName은 호출부에서 slotDisplayName으로 합성된 표시 라벨 const displayName = keyName || ''; - // 키의 중심이 마우스에 위치하도록 오프셋 계산 - const offsetX = duplicateCursor.x - width / 2; - const offsetY = duplicateCursor.y - height / 2; - return ( -
{renderGhostBorderRing(ghostImageReplaces)} @@ -1655,7 +1641,7 @@ const Grid = ({ {displayName}
)} -
+ ); }; diff --git a/src/renderer/components/main/Grid/layers/DuplicateGhostHost.tsx b/src/renderer/components/main/Grid/layers/DuplicateGhostHost.tsx new file mode 100644 index 000000000..89bd5a8fb --- /dev/null +++ b/src/renderer/components/main/Grid/layers/DuplicateGhostHost.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +// 복제 배치 미리보기의 공통 껍데기 - 커서를 중심에 둔 배치, 반투명, 드래그 프리뷰 층. +// 배경·보더·라운딩 같은 표면 표현은 요소 종류마다 달라 호출부가 넘긴다 +interface DuplicateGhostHostProps { + width: number; + height: number; + /** 커서 위치 (그리드 좌표) */ + cursor: { x: number; y: number }; + surfaceStyle?: React.CSSProperties; + /** 요소의 사용자 클래스 - 커스텀 CSS가 고스트에도 닿게 한다 */ + className?: string; + dataAttributes?: Record; + children?: React.ReactNode; +} + +const DuplicateGhostHost = ({ + width, + height, + cursor, + surfaceStyle, + className, + dataAttributes, + children, +}: DuplicateGhostHostProps) => ( +
+ {children} +
+); + +export default DuplicateGhostHost; diff --git a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx index d1e1f8e5e..223c987db 100644 --- a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx @@ -1,3 +1,4 @@ +import DuplicateGhostHost from './DuplicateGhostHost'; import { resolveImageSource } from '@utils/core/imageSource'; import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; import { @@ -26,18 +27,11 @@ const SpriteDuplicateGhost = ({ const ghostImage = resolveImageSource(position.baseImage); return ( -
{ghostImage && ( -
+ ); }; diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 73aa42806..2272cf21e 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -159,6 +159,21 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ } if (canonicals.size === 0) return undefined; + // 직접 쓴 src·visibility를 현재 문서의 기본 이미지로 되돌린다. + // 재생 종료·취소와 구독 해제가 같은 복원 규칙을 쓴다 + const showLatestBaseImage = (el: HTMLImageElement) => { + const currentBase = resolveImageSource( + latestRef.current.position.baseImage, + ); + if (currentBase) { + el.src = currentBase; + el.style.visibility = ''; + } else { + el.removeAttribute('src'); + el.style.visibility = 'hidden'; + } + }; + const handleEdge = () => { const el = imgRef.current; if (!el) return; @@ -194,16 +209,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ const restore = () => { if (playbackRef.current.generation !== generation) return; // 재생 중 문서가 바뀌었을 수 있어 캡처값 대신 최신 baseImage로 복원 - const currentBase = resolveImageSource( - latestRef.current.position.baseImage, - ); - if (currentBase) { - el.src = currentBase; - el.style.visibility = ''; - } else { - el.removeAttribute('src'); - el.style.visibility = 'hidden'; - } + showLatestBaseImage(el); }; const fromCss = spriteTransformToCss(resolved.transform); @@ -250,19 +256,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ // 직접 쓴 src·visibility 잔상 제거 - React는 prop이 같으면 DOM을 다시 // 쓰지 않으므로 여기서 현재 문서 기준으로 복원한다 (전환 시에는 key가 // 노드를 갈아 끼우고, 같은 분기 안 재구독에는 이 복원이 잡는다) - const el = mountedImg; - if (el) { - const currentBase = resolveImageSource( - latestRef.current.position.baseImage, - ); - if (currentBase) { - el.src = currentBase; - el.style.visibility = ''; - } else { - el.removeAttribute('src'); - el.style.visibility = 'hidden'; - } - } + if (mountedImg) showLatestBaseImage(mountedImg); }; }, [isOneShot, triggerIds, keyCanonicalMap]); From 9ed13c785f571e31be0bde5b5c840936df6d3c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:38:34 +0900 Subject: [PATCH 041/125] =?UTF-8?q?refactor:=20=EC=BA=94=EB=B2=84=EC=8A=A4?= =?UTF-8?q?=20=EC=9E=8E=20props=20=EA=B3=84=EC=95=BD=20=ED=86=B5=EC=9D=BC?= =?UTF-8?q?=EA=B3=BC=20=EC=A2=85=EB=A5=98=EB=B3=84=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 234 ++++-------------- .../components/main/Grid/layers/GraphItem.tsx | 39 +-- .../components/main/Grid/layers/KnobItem.tsx | 39 +-- .../main/Grid/layers/SpriteItem.tsx | 39 +-- .../main/Grid/layers/gridItemProps.ts | 37 +++ 5 files changed, 95 insertions(+), 293 deletions(-) create mode 100644 src/renderer/components/main/Grid/layers/gridItemProps.ts diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 939639d22..6fcb234ff 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -64,6 +64,7 @@ import GraphItem from '../layers/GraphItem'; import KnobItem from '../layers/KnobItem'; import SpriteItem from '../layers/SpriteItem'; import SpriteDuplicateGhost from '../layers/SpriteDuplicateGhost'; +import type { GridItemProps } from '../layers/gridItemProps'; import DuplicateGhostHost from '../layers/DuplicateGhostHost'; import { useGridSelectionStore, @@ -1279,199 +1280,47 @@ const Grid = ({ }); }; - const renderGraphItems = () => { - const items = graphPositions?.[selectedKeyType] || []; - if (!items.length) return null; - - const handleGraphPositionChange = ( - index: number, - dx: number, - dy: number, - elementId: string, - ) => { - commitElementPosition('graph', elementId, dx, dy); - }; - - return items.map((position, index) => ( - { - selectElementWithGroup('graph', index); - }} - onDoubleClick={() => openElementEditor('graph', index)} - onCtrlClick={() => { - toggleSelection({ - type: 'graph', - id: useGraphItemStore.getState().positions[selectedKeyType][index] - .id, - index, - }); - }} - onShiftClick={() => { - toggleSelection({ - type: 'graph', - id: useGraphItemStore.getState().positions[selectedKeyType][index] - .id, - index, - }); - }} - isSelected={selectedElements.some( - (el) => el.type === 'graph' && el.id === position.id, - )} - selectedElements={selectedElements} - onMultiDrag={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, undefined, false) - } - onMultiDragStart={beginSelectedPluginInstancesDrag} - onMultiDragEnd={commitSelectedElementsDrag} - activeTool={activeTool} - onEraserClick={() => { - void deleteElementById('graph', position.id).catch( - reportElementOpError, - ); - }} - onContextMenu={(e) => { - openElementContextMenu( - 'graph', - index, - e.clientX, - e.clientY, - graphRefs.current[index] || null, - ); - }} - zoom={zoom} - panX={panX} - panY={panY} - isViewportTransforming={isTransforming} - setReferenceRef={(node) => { - graphRefs.current[index] = node; - }} - /> - )); - }; - - const renderKnobItems = () => { - const items = knobPositions?.[selectedKeyType] || []; - if (!items.length) return null; + // 네이티브 요소 잎 렌더 - 그래프·노브·스프라이트는 상호작용 계약이 같아 + // 컴포넌트와 스토어만 갈아 끼운다. 선택 토글은 프리즈된 index를 스토어에서 + // 다시 읽어 id를 확정한다 (렌더 시점 배열이 아니라 최신 문서 기준) + const renderNativeItems = ( + elementType: 'graph' | 'knob' | 'sprite', + items: readonly T[] | undefined, + Component: React.ComponentType>, + idAtIndex: (index: number) => string, + refs: React.MutableRefObject<(HTMLElement | null)[]>, + ) => { + if (!items?.length) return null; - const handleKnobPositionChange = ( - index: number, + const handlePositionChange = ( + _index: number, dx: number, dy: number, elementId: string, ) => { - commitElementPosition('knob', elementId, dx, dy); + commitElementPosition(elementType, elementId, dx, dy); }; - return items.map((position, index) => ( - { - selectElementWithGroup('knob', index); - }} - onDoubleClick={() => openElementEditor('knob', index)} - onCtrlClick={() => { - toggleSelection({ - type: 'knob', - id: useKnobItemStore.getState().positions[selectedKeyType][index] - .id, - index, - }); - }} - onShiftClick={() => { - toggleSelection({ - type: 'knob', - id: useKnobItemStore.getState().positions[selectedKeyType][index] - .id, - index, - }); - }} - isSelected={selectedElements.some( - (el) => el.type === 'knob' && el.id === position.id, - )} - selectedElements={selectedElements} - onMultiDrag={(deltaX, deltaY) => - moveSelectedElements(deltaX, deltaY, undefined, false) - } - onMultiDragStart={beginSelectedPluginInstancesDrag} - onMultiDragEnd={commitSelectedElementsDrag} - activeTool={activeTool} - onEraserClick={() => { - void deleteElementById('knob', position.id).catch( - reportElementOpError, - ); - }} - onContextMenu={(e) => { - openElementContextMenu( - 'knob', - index, - e.clientX, - e.clientY, - knobRefs.current[index] || null, - ); - }} - zoom={zoom} - panX={panX} - panY={panY} - isViewportTransforming={isTransforming} - setReferenceRef={(node) => { - knobRefs.current[index] = node; - }} - /> - )); - }; - - const renderSpriteItems = () => { - const items = spritePositions?.[selectedKeyType] || []; - if (!items.length) return null; - - const handleSpritePositionChange = ( - index: number, - dx: number, - dy: number, - elementId: string, - ) => { - commitElementPosition('sprite', elementId, dx, dy); + const toggleAtIndex = (index: number) => { + toggleSelection({ type: elementType, id: idAtIndex(index), index }); }; return items.map((position, index) => ( - { - selectElementWithGroup('sprite', index); - }} - onDoubleClick={() => openElementEditor('sprite', index)} - onCtrlClick={() => { - toggleSelection({ - type: 'sprite', - id: useSpriteStore.getState().positions[selectedKeyType][index].id, - index, - }); - }} - onShiftClick={() => { - toggleSelection({ - type: 'sprite', - id: useSpriteStore.getState().positions[selectedKeyType][index].id, - index, - }); + selectElementWithGroup(elementType, index); }} + onDoubleClick={() => openElementEditor(elementType, index)} + onCtrlClick={() => toggleAtIndex(index)} + onShiftClick={() => toggleAtIndex(index)} isSelected={selectedElements.some( - (el) => el.type === 'sprite' && el.id === position.id, + (el) => el.type === elementType && el.id === position.id, )} selectedElements={selectedElements} onMultiDrag={(deltaX, deltaY) => @@ -1481,17 +1330,17 @@ const Grid = ({ onMultiDragEnd={commitSelectedElementsDrag} activeTool={activeTool} onEraserClick={() => { - void deleteElementById('sprite', position.id).catch( + void deleteElementById(elementType, position.id).catch( reportElementOpError, ); }} onContextMenu={(e) => { openElementContextMenu( - 'sprite', + elementType, index, e.clientX, e.clientY, - spriteRefs.current[index] || null, + refs.current[index] || null, ); }} zoom={zoom} @@ -1499,7 +1348,7 @@ const Grid = ({ panY={panY} isViewportTransforming={isTransforming} setReferenceRef={(node) => { - spriteRefs.current[index] = node; + refs.current[index] = node; }} /> )); @@ -1750,9 +1599,30 @@ const Grid = ({ > {renderKeys()} {renderStatItems()} - {renderGraphItems()} - {renderKnobItems()} - {renderSpriteItems()} + {renderNativeItems( + 'graph', + graphPositions?.[selectedKeyType], + GraphItem, + (index) => + useGraphItemStore.getState().positions[selectedKeyType][index].id, + graphRefs, + )} + {renderNativeItems( + 'knob', + knobPositions?.[selectedKeyType], + KnobItem, + (index) => + useKnobItemStore.getState().positions[selectedKeyType][index].id, + knobRefs, + )} + {renderNativeItems( + 'sprite', + spritePositions?.[selectedKeyType], + SpriteItem, + (index) => + useSpriteStore.getState().positions[selectedKeyType][index].id, + spriteRefs, + )} {/* Outside 카운터 미리보기 레이어 */} {keyCounterEnabled && ( void; - onClick?: (e: React.MouseEvent) => void; - onDoubleClick?: (e: React.MouseEvent) => void; - onCtrlClick?: (e: React.MouseEvent) => void; - onShiftClick?: (e: React.MouseEvent) => void; - isSelected?: boolean; - selectedElements?: SelectedElement[]; - onMultiDrag?: (dx: number, dy: number) => void; - onMultiDragStart?: () => void | (() => void); - onMultiDragEnd?: () => void; - activeTool?: string; - onEraserClick?: () => void; - onContextMenu?: (e: React.MouseEvent) => void; - setReferenceRef?: (node: HTMLElement | null) => void; - zoom?: number; - panX?: number; - panY?: number; - zIndex?: number; - isViewportTransforming?: boolean; -} - const PREVIEW_HISTORY_BASE: number[] = [ 8, 10, 11, 13, 12, 14, 15, 16, 14, 13, 12, 14, 15, 14, 14, ]; @@ -95,7 +60,7 @@ const GraphItem = ({ panY = 0, zIndex = 0, isViewportTransforming = false, -}: GraphItemProps) => { +}: GridItemProps) => { const { dx = 0, dy = 0, diff --git a/src/renderer/components/main/Grid/layers/KnobItem.tsx b/src/renderer/components/main/Grid/layers/KnobItem.tsx index 5cb56f869..7c199ee95 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.tsx @@ -23,6 +23,7 @@ import { resolveElementShadow, type ElementShadowSpec, } from '@src/types/key/shadows'; +import type { GridItemProps } from './gridItemProps'; interface KnobPosition { hidden?: boolean; @@ -54,42 +55,6 @@ interface KnobPosition { zIndex?: number; } -interface SelectedElement { - id: string; - type?: string; - index?: number; -} - -interface KnobItemProps { - index: number; - elementId: string; - position: KnobPosition; - onPositionChange: ( - index: number, - dx: number, - dy: number, - elementId: string, - ) => void; - onClick?: (e: React.MouseEvent) => void; - onDoubleClick?: (e: React.MouseEvent) => void; - onCtrlClick?: (e: React.MouseEvent) => void; - onShiftClick?: (e: React.MouseEvent) => void; - isSelected?: boolean; - selectedElements?: SelectedElement[]; - onMultiDrag?: (dx: number, dy: number) => void; - onMultiDragStart?: () => void | (() => void); - onMultiDragEnd?: () => void; - activeTool?: string; - onEraserClick?: () => void; - onContextMenu?: (e: React.MouseEvent) => void; - setReferenceRef?: (node: HTMLElement | null) => void; - zoom?: number; - panX?: number; - panY?: number; - zIndex?: number; - isViewportTransforming?: boolean; -} - const KnobItem = ({ index, elementId, @@ -113,7 +78,7 @@ const KnobItem = ({ panY = 0, zIndex = 0, isViewportTransforming = false, -}: KnobItemProps) => { +}: GridItemProps) => { const { dx = 0, dy = 0, diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index 411f60c01..15c9df43f 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -10,42 +10,7 @@ import { } from '@utils/grid/activityAreaGuide'; import { DEFAULT_SPRITE_SIZE } from '@src/types/key/sprites'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; - -interface SelectedElement { - id: string; - type?: string; - index?: number; -} - -interface SpriteItemProps { - index: number; - elementId: string; - position: CanonicalReactiveSpritePosition; - onPositionChange: ( - index: number, - dx: number, - dy: number, - elementId: string, - ) => void; - onClick?: (e: React.MouseEvent) => void; - onDoubleClick?: (e: React.MouseEvent) => void; - onCtrlClick?: (e: React.MouseEvent) => void; - onShiftClick?: (e: React.MouseEvent) => void; - isSelected?: boolean; - selectedElements?: SelectedElement[]; - onMultiDrag?: (dx: number, dy: number) => void; - onMultiDragStart?: () => void | (() => void); - onMultiDragEnd?: () => void; - activeTool?: string; - onEraserClick?: () => void; - onContextMenu?: (e: React.MouseEvent) => void; - setReferenceRef?: (node: HTMLElement | null) => void; - zoom?: number; - panX?: number; - panY?: number; - zIndex?: number; - isViewportTransforming?: boolean; -} +import type { GridItemProps } from './gridItemProps'; // 활동 영역은 에디터 전용 가이드 - 오버레이에는 그리지 않는다. // 이미지가 있으면 점선이 소음이라 호버에만, 없으면 가이드가 유일한 실체라 상시. @@ -83,7 +48,7 @@ const SpriteItem = ({ panY = 0, zIndex = 0, isViewportTransforming = false, -}: SpriteItemProps) => { +}: GridItemProps) => { const { dx = 0, dy = 0, diff --git a/src/renderer/components/main/Grid/layers/gridItemProps.ts b/src/renderer/components/main/Grid/layers/gridItemProps.ts new file mode 100644 index 000000000..1da060f56 --- /dev/null +++ b/src/renderer/components/main/Grid/layers/gridItemProps.ts @@ -0,0 +1,37 @@ +import type React from 'react'; + +import type { GridItemSelectedElement } from '@hooks/Grid/useGridItemInteraction'; + +/** + * 캔버스 잎 컴포넌트의 공통 props. 요소 데이터 타입만 다르고 상호작용 계약은 같다 - + * 그리드가 종류별 렌더를 하나의 함수로 접을 수 있는 근거 + */ +export interface GridItemProps { + index: number; + elementId: string; + position: TPosition; + onPositionChange: ( + index: number, + dx: number, + dy: number, + elementId: string, + ) => void; + onClick?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; + onCtrlClick?: (e: React.MouseEvent) => void; + onShiftClick?: (e: React.MouseEvent) => void; + isSelected?: boolean; + selectedElements?: GridItemSelectedElement[]; + onMultiDrag?: (dx: number, dy: number) => void; + onMultiDragStart?: () => void | (() => void); + onMultiDragEnd?: () => void; + activeTool?: string; + onEraserClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; + setReferenceRef?: (node: HTMLElement | null) => void; + zoom?: number; + panX?: number; + panY?: number; + zIndex?: number; + isViewportTransforming?: boolean; +} From bf5aab3ecbd7cfc6362c64d83cbb6781ecdae6cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:39:59 +0900 Subject: [PATCH 042/125] =?UTF-8?q?refactor:=20bounds=20intent=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20=EA=B7=9C=EC=B9=99=20=EB=8B=A8=EC=9D=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/editor/runtime/elementOps.ts | 52 ++++++++++++------- .../editor/runtime/mixedBatchGeometry.ts | 21 +++----- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/renderer/editor/runtime/elementOps.ts b/src/renderer/editor/runtime/elementOps.ts index 1d38e598d..af9d4d02f 100644 --- a/src/renderer/editor/runtime/elementOps.ts +++ b/src/renderer/editor/runtime/elementOps.ts @@ -2460,30 +2460,17 @@ export const commitBatchGeometryByIds = ( if (!initialPlan || initialPlan.updates.length === 0) { return Promise.resolve(false); } - const intents = new Map< - NativeElementType, - Map> - >(); const targetById = new Map( frozenDescriptor.targets.map((target) => [target.id, target] as const), ); - // 스프라이트 eager는 부분 patch가 아니라 full bounds로 projection해야 - // 콘텐츠 스케일까지 싣는다 const fullBoundsByKey = new Map( initialPlan.bounds.map(({ key, bounds }) => [key, bounds] as const), ); - for (const { key, patch } of initialPlan.updates) { - const target = targetById.get(key); - if (!target) continue; - const byId = intents.get(target.type) ?? new Map(); - const spriteBounds = - target.type === 'sprite' ? fullBoundsByKey.get(key) : undefined; - byId.set( - target.id, - (spriteBounds ? { ...spriteBounds } : patch) as Record, - ); - intents.set(target.type, byId); - } + const intents = buildNativeBoundsIntents( + initialPlan.updates, + (key) => targetById.get(key), + (_target, key) => fullBoundsByKey.get(key), + ); const receipt = intents.size > 0 ? applyBoundsIntentsEagerly(intents) : null; let enrolled = false; return commitGeneratedSemanticOps( @@ -2613,6 +2600,35 @@ const spriteResizeEagerIntent = ( return { ...bounds }; }; +// bounds intent 맵 조립 - 이동 계열은 부분 patch, 스프라이트는 full bounds를 +// 실어야 eager 적용이 콘텐츠 스케일까지 소유한다. 단일 배치와 혼합 배치가 +// 같은 규칙을 쓰되 스프라이트 bounds를 찾는 키(계획 key vs op id)만 다르다 +export const buildNativeBoundsIntents = < + TTarget extends { id: string; type: NativeElementType }, +>( + updates: readonly { key: string; patch: Record }[], + targetForKey: (key: string) => TTarget | undefined, + spriteBoundsFor: (target: TTarget, key: string) => EditorBoundsV1 | undefined, +): PropertyIntents => { + const intents = new Map< + NativeElementType, + Map> + >(); + for (const { key, patch } of updates) { + const target = targetForKey(key); + if (!target) continue; + const byId = intents.get(target.type) ?? new Map(); + const spriteBounds = + target.type === 'sprite' ? spriteBoundsFor(target, key) : undefined; + byId.set( + target.id, + (spriteBounds ? { ...spriteBounds } : patch) as Record, + ); + intents.set(target.type, byId); + } + return intents; +}; + // bounds intent의 eager 적용 - 스프라이트 항목은 projection 결과(콘텐츠 // 포함)로 치환해 receipt가 스케일 필드까지 소유하게 한다 export const applyBoundsIntentsEagerly = ( diff --git a/src/renderer/editor/runtime/mixedBatchGeometry.ts b/src/renderer/editor/runtime/mixedBatchGeometry.ts index 2bbcf537f..0e2c75d01 100644 --- a/src/renderer/editor/runtime/mixedBatchGeometry.ts +++ b/src/renderer/editor/runtime/mixedBatchGeometry.ts @@ -27,6 +27,7 @@ import { } from './elementIntent'; import { applyBoundsIntentsEagerly, + buildNativeBoundsIntents, commitBatchGeometryByIds, elementBoundsOp, type BatchGeometryDescriptor, @@ -367,26 +368,16 @@ export const commitMixedBatchGeometry = ( // eager: native 기하 필드 CAS + 플러그인 position CAS. // 스프라이트는 full bounds projection이 콘텐츠까지 소유한다 - const nativeIntents = new Map< - NativeElementType, - Map> - >(); const spriteBoundsById = new Map( initialPlanned.nativeOps.flatMap((op) => op.kind === 'resizeSprite' ? [[op.id, op.bounds] as const] : [], ), ); - for (const { key, patch } of initialPlanned.updates) { - const target = targetByKey.get(key); - if (!target) continue; - const byId = nativeIntents.get(target.type) ?? new Map(); - const spriteBounds = - target.type === 'sprite' - ? spriteBoundsById.get(target.id) - : undefined; - byId.set(target.id, spriteBounds ? { ...spriteBounds } : patch); - nativeIntents.set(target.type, byId); - } + const nativeIntents = buildNativeBoundsIntents( + initialPlanned.updates, + (key) => targetByKey.get(key), + (target) => spriteBoundsById.get(target.id), + ); const nativeReceipt = nativeIntents.size > 0 ? applyBoundsIntentsEagerly(nativeIntents) From 663217f93215ed964b93d73359ebf06b55d7b507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 04:44:26 +0900 Subject: [PATCH 043/125] =?UTF-8?q?test:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=94=BD=EC=8A=A4=EC=B2=98=20=EB=B9=8C?= =?UTF-8?q?=EB=8D=94=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/layers/SpriteItem.render.test.tsx | 35 +++------- .../counters/overlaySpriteItem.test.tsx | 58 +++++++--------- .../grid/useSpriteEditPreviewStore.test.ts | 10 +-- .../utils/sprite/poseResolver.test.ts | 48 +++++-------- src/renderer/utils/sprite/spriteFixtures.ts | 69 +++++++++++++++++++ .../utils/sprite/spritePoseNames.test.ts | 11 +-- src/renderer/utils/sprite/spriteReach.test.ts | 15 ++-- .../utils/sprite/spriteWireShape.test.ts | 33 ++------- 8 files changed, 142 insertions(+), 137 deletions(-) create mode 100644 src/renderer/utils/sprite/spriteFixtures.ts diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index 9d6cf61bb..3846a44bb 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -10,6 +10,7 @@ import SpriteItem from '@components/main/Grid/layers/SpriteItem'; import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import type { SpritePose } from '@src/types/key/sprites'; +import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -56,30 +57,16 @@ const SPRITE_ID = '00000000-0000-4000-8000-000000000801'; const spritePosition = ( overrides: Partial = {}, -): CanonicalReactiveSpritePosition => ({ - activation: 'whileHeld', - pressDurationMs: 300, - id: SPRITE_ID, - dx: 10, - dy: 20, - width: 200, - height: 200, - hidden: false, - zIndex: null, - layerName: null, - groupId: null, - className: null, - useInlineStyles: null, - baseImage: null, - imageFit: 'contain', - imageRect: { x: 0, y: 0, width: 200, height: 200 }, - pivot: { x: 0.5, y: 0.5 }, - idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, - poses: [], - transitionMs: 90, - transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', - ...overrides, -}); +): CanonicalReactiveSpritePosition => + makeCanonicalSpritePosition({ + id: SPRITE_ID, + dx: 10, + dy: 20, + layerName: null, + groupId: null, + imageFit: 'contain', + ...overrides, + }); let container: HTMLDivElement; let root: Root; diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index f7eae8b64..fa7b312bd 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -16,6 +16,10 @@ import { } from '@stores/signals/keySignals'; import OverlaySpriteItem from './OverlaySpriteItem'; +import { + makeCanonicalSpritePosition, + makeSpritePose, +} from '@utils/sprite/spriteFixtures'; ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -28,41 +32,31 @@ const makePose = ( poseId: string, triggers: string[], overrides: Partial = {}, -): SpritePose => ({ - contactPoint: { x: 0.5, y: 1 }, - poseId, - triggers, - transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, - imageOverride: null, - ...overrides, -}); +): SpritePose => + makeSpritePose({ + poseId, + triggers, + transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, + ...overrides, + }); const makeSprite = ( overrides: Partial = {}, -): CanonicalReactiveSpritePosition => ({ - activation: 'whileHeld', - pressDurationMs: 300, - id: 'sprite-1', - dx: 12, - dy: 24, - width: 300, - height: 200, - hidden: false, - zIndex: 5, - layerName: null, - groupId: null, - className: null, - useInlineStyles: null, - baseImage: BASE_IMAGE, - imageFit: null, - imageRect: { x: 10, y: 20, width: 120, height: 80 }, - pivot: { x: 0.5, y: 1 }, - idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, - poses: [makePose('p1', ['el-a'])], - transitionMs: 90, - transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', - ...overrides, -}); +): CanonicalReactiveSpritePosition => + makeCanonicalSpritePosition({ + dx: 12, + dy: 24, + width: 300, + height: 200, + zIndex: 5, + layerName: null, + groupId: null, + baseImage: BASE_IMAGE, + imageRect: { x: 10, y: 20, width: 120, height: 80 }, + pivot: { x: 0.5, y: 1 }, + poses: [makePose('p1', ['el-a'])], + ...overrides, + }); const keyCanonicalMap: ReadonlyMap = new Map([ ['el-a', 'KeyA'], diff --git a/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts index 4e3aa6cc9..d8c83d666 100644 --- a/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts +++ b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts @@ -2,14 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { useSpriteEditPreviewStore } from './useSpriteEditPreviewStore'; import type { SpritePose } from '@src/types/key/sprites'; +import { makeSpritePose } from '@utils/sprite/spriteFixtures'; -const pose = (poseId: string): SpritePose => ({ - contactPoint: { x: 0.5, y: 1 }, - poseId, - triggers: ['k1'], - transform: { x: 0, y: 0, rotation: 0, scale: 1 }, - imageOverride: null, -}); +const pose = (poseId: string): SpritePose => + makeSpritePose({ poseId, triggers: ['k1'] }); describe('useSpriteEditPreviewStore', () => { afterEach(() => { diff --git a/src/renderer/utils/sprite/poseResolver.test.ts b/src/renderer/utils/sprite/poseResolver.test.ts index 1136a5686..fab1cc6bd 100644 --- a/src/renderer/utils/sprite/poseResolver.test.ts +++ b/src/renderer/utils/sprite/poseResolver.test.ts @@ -7,6 +7,7 @@ import type { } from '@src/types/key/sprites'; import { resolveSpriteTarget } from './poseResolver'; +import { makeSpritePose, makeSpritePosition } from './spriteFixtures'; const makeTransform = ( overrides: Partial = {}, @@ -23,40 +24,27 @@ const makePose = ( triggers: string[], transform: Partial = {}, imageOverride: string | null = null, -): SpritePose => ({ - contactPoint: { x: 0.5, y: 1 }, - poseId, - triggers, - transform: makeTransform(transform), - imageOverride, -}); +): SpritePose => + makeSpritePose({ + poseId, + triggers, + transform: makeTransform(transform), + imageOverride, + }); const makeSprite = ( poses: SpritePose[], overrides: Partial = {}, -): ReactiveSpritePosition => ({ - activation: 'whileHeld', - pressDurationMs: 300, - dx: 0, - dy: 0, - width: 200, - height: 120, - hidden: false, - zIndex: null, - layerName: null, - groupId: null, - className: null, - useInlineStyles: null, - baseImage: 'base.png', - imageFit: null, - imageRect: { x: 0, y: 0, width: 100, height: 100 }, - pivot: { x: 0.5, y: 0.5 }, - idleTransform: makeTransform(), - poses, - transitionMs: 90, - transitionEasing: 'linear', - ...overrides, -}); +): ReactiveSpritePosition => + makeSpritePosition({ + width: 200, + height: 120, + baseImage: 'base.png', + imageRect: { x: 0, y: 0, width: 100, height: 100 }, + transitionEasing: 'linear', + poses, + ...overrides, + }); // 진리표 기준 스프라이트: 키 요소 kA, kS, kD와 단일 자세 3개 const poseA = makePose('poseA', ['kA'], { x: 10, y: -30, rotation: -30 }); diff --git a/src/renderer/utils/sprite/spriteFixtures.ts b/src/renderer/utils/sprite/spriteFixtures.ts new file mode 100644 index 000000000..910a5b89c --- /dev/null +++ b/src/renderer/utils/sprite/spriteFixtures.ts @@ -0,0 +1,69 @@ +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; +import type { + ReactiveSpritePosition, + SpritePose, +} from '@src/types/key/sprites'; +import { + DEFAULT_SPRITE_ACTIVATION, + DEFAULT_SPRITE_CONTACT_POINT, + DEFAULT_SPRITE_PRESS_DURATION_MS, + DEFAULT_SPRITE_SIZE, + DEFAULT_SPRITE_TRANSITION_EASING, + IDENTITY_SPRITE_TRANSFORM, + CENTER_SPRITE_ANCHOR, +} from '@src/types/key/sprites'; + +// 테스트 전용 스프라이트 빌더. 스키마에 필수 필드가 늘 때 테스트 20여 곳을 +// 각자 고치지 않도록 전체 형태를 한 곳에서 만든다. +// 프로덕션 코드는 이 모듈을 import하지 않는다 + +export const makeSpritePose = ( + overrides: Partial = {}, +): SpritePose => ({ + poseId: 'pose-1', + triggers: [], + transform: { ...IDENTITY_SPRITE_TRANSFORM }, + imageOverride: null, + contactPoint: { ...DEFAULT_SPRITE_CONTACT_POINT }, + ...overrides, +}); + +export const makeSpritePosition = ( + overrides: Partial = {}, +): ReactiveSpritePosition => ({ + id: 'sprite-1', + dx: 0, + dy: 0, + width: DEFAULT_SPRITE_SIZE, + height: DEFAULT_SPRITE_SIZE, + hidden: false, + zIndex: null, + // layerName·groupId는 백엔드가 None이면 키 자체를 생략한다 - 기본형도 그 형태를 + // 따르고, 명시 null이 필요한 테스트만 overrides로 넣는다 + className: null, + useInlineStyles: null, + baseImage: null, + imageFit: null, + imageRect: { + x: 0, + y: 0, + width: DEFAULT_SPRITE_SIZE, + height: DEFAULT_SPRITE_SIZE, + }, + pivot: { ...CENTER_SPRITE_ANCHOR }, + idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, + activation: DEFAULT_SPRITE_ACTIVATION, + pressDurationMs: DEFAULT_SPRITE_PRESS_DURATION_MS, + poses: [], + transitionMs: 90, + transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, + ...overrides, +}); + +/** 서빙 문서에 들어가는 형태 - 요소 id가 확정돼 있다 */ +export const makeCanonicalSpritePosition = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => ({ + ...makeSpritePosition(overrides), + id: overrides.id ?? 'sprite-1', +}); diff --git a/src/renderer/utils/sprite/spritePoseNames.test.ts b/src/renderer/utils/sprite/spritePoseNames.test.ts index 43ef7b6ad..1db2d58c5 100644 --- a/src/renderer/utils/sprite/spritePoseNames.test.ts +++ b/src/renderer/utils/sprite/spritePoseNames.test.ts @@ -8,18 +8,13 @@ import { resolvePoseNames, stripCopySuffix, } from './spritePoseNames'; +import { makeSpritePose } from './spriteFixtures'; const LABEL = '상태'; const SUFFIX = '복제'; -const pose = (poseId: string, name: string | null = null): SpritePose => ({ - poseId, - name, - triggers: [], - transform: { x: 0, y: 0, rotation: 0, scale: 1 }, - imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, -}); +const pose = (poseId: string, name: string | null = null): SpritePose => + makeSpritePose({ poseId, name }); describe('resolvePoseNames', () => { it('무명 자세에 1부터 번호를 매긴다', () => { diff --git a/src/renderer/utils/sprite/spriteReach.test.ts b/src/renderer/utils/sprite/spriteReach.test.ts index 1c76f4875..1c0b80b26 100644 --- a/src/renderer/utils/sprite/spriteReach.test.ts +++ b/src/renderer/utils/sprite/spriteReach.test.ts @@ -11,6 +11,7 @@ import { SPRITE_SAFE_FALLBACK_EASING, type SpriteReachGeometry, } from './spriteReach'; +import { makeSpritePose } from './spriteFixtures'; const OVERSHOOT_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'; @@ -28,13 +29,13 @@ const makePose = ( poseId: string, transform: Partial = {}, imageOverride: string | null = null, -): SpritePose => ({ - contactPoint: { x: 0.5, y: 1 }, - poseId, - triggers: [poseId], - transform: makeTransform(transform), - imageOverride, -}); +): SpritePose => + makeSpritePose({ + poseId, + triggers: [poseId], + transform: makeTransform(transform), + imageOverride, + }); // 활동 영역 200x200에 imageRect가 영역 전체, pivot 중앙 (기본 생성값 형태) const makeSprite = ( diff --git a/src/renderer/utils/sprite/spriteWireShape.test.ts b/src/renderer/utils/sprite/spriteWireShape.test.ts index 65d5bdeec..b6b72f758 100644 --- a/src/renderer/utils/sprite/spriteWireShape.test.ts +++ b/src/renderer/utils/sprite/spriteWireShape.test.ts @@ -1,3 +1,4 @@ +import { makeSpritePose, makeSpritePosition } from './spriteFixtures'; import { describe, expect, it } from 'vitest'; import type { @@ -7,36 +8,10 @@ import type { import { toSpriteWireShape } from './spriteWireShape'; -const basePose = (overrides: Partial = {}): SpritePose => ({ - contactPoint: { x: 0.5, y: 1 }, - poseId: 'pose-1', - triggers: ['key-1'], - transform: { x: 0, y: 0, rotation: 0, scale: 1 }, - imageOverride: null, - ...overrides, -}); +const basePose = (overrides: Partial = {}): SpritePose => + makeSpritePose({ triggers: ['key-1'], ...overrides }); -const basePosition = (): ReactiveSpritePosition => ({ - activation: 'whileHeld', - pressDurationMs: 300, - id: 'sprite-1', - dx: 0, - dy: 0, - width: 200, - height: 200, - hidden: false, - zIndex: null, - className: null, - useInlineStyles: null, - baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 200, height: 200 }, - pivot: { x: 0.5, y: 0.5 }, - idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, - poses: [], - transitionMs: 90, - transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', -}); +const basePosition = (): ReactiveSpritePosition => makeSpritePosition(); describe('toSpriteWireShape', () => { it('명시 null layerName·groupId 키를 제거한다', () => { From 34bed51e439ee54c1b0fbe4fe69cf24b8c276623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 05:03:18 +0900 Subject: [PATCH 044/125] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EC=BD=9C=EB=B0=B1=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EC=9E=AC=EC=A7=84=EC=9E=85=20=EA=B0=80=EB=93=9C=EC=99=80=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pickers/ImagePicker.editSession.test.tsx | 33 +++++++++++++++++++ .../Modal/content/pickers/ImagePicker.tsx | 27 +++++++++------ src/renderer/utils/core/pickValidatedImage.ts | 12 +++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx index 0b819118f..6cbaf90fb 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.editSession.test.tsx @@ -129,6 +129,39 @@ describe('ImagePicker 비동기 완료와 대상 전환', () => { expect(onIdleImageChange).toHaveBeenCalledWith('/tmp/picked.png'); }); + // 연결 콜백이 던져도 재진입 가드는 반드시 풀려야 한다. + // 안 풀리면 그 뒤로 이미지 선택 버튼이 영영 죽는다 + it('연결 콜백이 던져도 로그만 남기고 다음 선택이 가능하다', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + onIdleImageChange.mockImplementationOnce(() => { + throw new Error('boom'); + }); + mount(true); + clickPreview(); + await finishLoad(); + + expect(consoleError).toHaveBeenCalled(); + + clickPreview(); + await finishLoad({ success: true, imagePath: '/tmp/second.png' }); + expect(onIdleImageChange).toHaveBeenLastCalledWith('/tmp/second.png'); + consoleError.mockRestore(); + }); + + // 콜백 안에서 다시 누르는 경로 - 가드가 콜백보다 먼저 풀리면 두 번째 파일창이 뜬다 + it('연결 콜백 중 다시 눌러도 두 번째 파일창을 열지 않는다', async () => { + onIdleImageChange.mockImplementationOnce(() => { + clickPreview(); + }); + mount(true); + clickPreview(); + await finishLoad(); + + expect(apiMocks.imageLoad).toHaveBeenCalledTimes(1); + }); + it('대기 중 모드가 바뀌면 연결하지 않는다', async () => { mount(true); clickPreview(); diff --git a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx index 5e2bcea76..04a6da4b3 100644 --- a/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx +++ b/src/renderer/components/main/Modal/content/pickers/ImagePicker.tsx @@ -11,7 +11,10 @@ import { } from '@components/main/common/TransformGlyphs'; import { PropertySection } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; import { resolveImageSource } from '@utils/core/imageSource'; -import { pickValidatedImagePath } from '@utils/core/pickValidatedImage'; +import { + pickValidatedImagePath, + runImageCompletion, +} from '@utils/core/pickValidatedImage'; import { useEditSessionCompletionGuard } from '@src/renderer/contexts/EditSessionScope'; import type { CompletionBinding } from '@src/renderer/contexts/EditSessionScope'; @@ -136,17 +139,21 @@ const ImagePicker = ({ loadingImageRef.current = true; setIsLoadingImage(true); const picked = await pickValidatedImagePath(t); + if (picked) { + // 파일 복사는 이미 끝났다. 대상이 갈렸으면 연결만 하지 않는다 + // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과). + // 재진입 가드는 연결까지 끝난 뒤에 푼다 - 콜백 안에서 다시 누르는 경로 차단 + runImageCompletion(() => { + if (!canBindCompletion()) return; + if (stateMode === STATE_MODES.idle) { + onIdleImageChange?.(picked); + } else { + onActiveImageChange?.(picked); + } + }); + } loadingImageRef.current = false; setIsLoadingImage(false); - if (!picked) return; - // 파일 복사는 이미 끝났다. 대상이 갈렸으면 연결만 하지 않는다 - // (element-id 결합이면 ID applier가 유효성을 판정하므로 통과) - if (!canBindCompletion()) return; - if (stateMode === STATE_MODES.idle) { - onIdleImageChange?.(picked); - } else { - onActiveImageChange?.(picked); - } }; const handleReset = (): void => { diff --git a/src/renderer/utils/core/pickValidatedImage.ts b/src/renderer/utils/core/pickValidatedImage.ts index 03e50829b..851d56388 100644 --- a/src/renderer/utils/core/pickValidatedImage.ts +++ b/src/renderer/utils/core/pickValidatedImage.ts @@ -44,3 +44,15 @@ export const pickValidatedImagePath = async ( return null; } }; + +/** + * 선택 완료 처리를 감싸 예외가 재진입 가드 해제를 건너뛰지 못하게 한다. + * 호출부가 try를 들면 그 컴포넌트가 React Compiler 최적화에서 빠지므로 여기서 삼킨다 + */ +export const runImageCompletion = (complete: () => void): void => { + try { + complete(); + } catch (error) { + console.error('Failed to apply picked image', error); + } +}; From 11501055c4bb9de2053e49a8d24917e853da7b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 05:03:18 +0900 Subject: [PATCH 045/125] =?UTF-8?q?test:=20=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=83=81=ED=98=B8=EC=9E=91?= =?UTF-8?q?=EC=9A=A9=20=ED=9B=85=20=EA=B3=84=EC=95=BD=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20=EC=A3=BD=EC=9D=80=20mock=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layers/GraphItem.trailingClick.test.tsx | 15 -- .../layers/KnobItem.trailingClick.test.tsx | 15 -- .../Grid/layers/SpriteItem.render.test.tsx | 15 -- .../shared/Key.statePreview.test.tsx | 15 -- .../shared/Key.trailingClick.test.tsx | 15 -- .../Grid/useGridItemInteraction.test.tsx | 200 ++++++++++++++++++ .../hooks/Grid/useGridItemInteraction.ts | 2 - 7 files changed, 200 insertions(+), 77 deletions(-) create mode 100644 src/renderer/hooks/Grid/useGridItemInteraction.test.tsx diff --git a/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx b/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx index 1c7ffdee4..7adf10e71 100644 --- a/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx +++ b/src/renderer/components/main/Grid/layers/GraphItem.trailingClick.test.tsx @@ -19,21 +19,6 @@ const hookState = vi.hoisted(() => ({ wasMoved: false, pressMoved: false })); // 수식키 판별을 ctrlKey로 고정 vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: hookState.wasMoved, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); - // 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 vi.mock('@hooks/Grid/useDraggable', () => ({ useDraggable: () => ({ diff --git a/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx b/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx index 50466996d..4999e9ce5 100644 --- a/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx +++ b/src/renderer/components/main/Grid/layers/KnobItem.trailingClick.test.tsx @@ -19,21 +19,6 @@ const hookState = vi.hoisted(() => ({ wasMoved: false, pressMoved: false })); // 수식키 판별을 ctrlKey로 고정 vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: hookState.wasMoved, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); - // 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 vi.mock('@hooks/Grid/useDraggable', () => ({ useDraggable: () => ({ diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index 3846a44bb..5e95e921c 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -18,21 +18,6 @@ import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: false, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); - // 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 vi.mock('@hooks/Grid/useDraggable', () => ({ useDraggable: () => ({ diff --git a/src/renderer/components/shared/Key.statePreview.test.tsx b/src/renderer/components/shared/Key.statePreview.test.tsx index cb31fd624..1b1b13bf0 100644 --- a/src/renderer/components/shared/Key.statePreview.test.tsx +++ b/src/renderer/components/shared/Key.statePreview.test.tsx @@ -18,21 +18,6 @@ import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: false, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); - // 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 vi.mock('@hooks/Grid/useDraggable', () => ({ useDraggable: () => ({ diff --git a/src/renderer/components/shared/Key.trailingClick.test.tsx b/src/renderer/components/shared/Key.trailingClick.test.tsx index 31b2520ca..a811d03aa 100644 --- a/src/renderer/components/shared/Key.trailingClick.test.tsx +++ b/src/renderer/components/shared/Key.trailingClick.test.tsx @@ -21,21 +21,6 @@ const hookState = vi.hoisted(() => ({ wasMoved: false, pressMoved: false })); // 수식키 판별을 ctrlKey로 고정 vi.mock('@utils/core/platform', () => ({ isMac: () => false })); -vi.mock('@hooks/Grid', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useDraggable: () => ({ - ref: () => {}, - dx: 0, - dy: 0, - wasMoved: hookState.wasMoved, - isDragging: false, - recentPressMovedRef: { current: false }, - }), - }; -}); - // 공용 상호작용 훅은 배럴이 아니라 소스 모듈을 직접 쓴다 vi.mock('@hooks/Grid/useDraggable', () => ({ useDraggable: () => ({ diff --git a/src/renderer/hooks/Grid/useGridItemInteraction.test.tsx b/src/renderer/hooks/Grid/useGridItemInteraction.test.tsx new file mode 100644 index 000000000..b818a7dbb --- /dev/null +++ b/src/renderer/hooks/Grid/useGridItemInteraction.test.tsx @@ -0,0 +1,200 @@ +/** + * 캔버스 아이템 공용 상호작용 훅의 계약 고정 + * 키·그래프·노브·스프라이트 잎이 전부 이 훅에 의존하므로 흡수 순서·가드·부착 순서를 + * 잎 컴포넌트가 아니라 여기서 직접 잡는다 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useGridItemInteraction } from './useGridItemInteraction'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const hookState = vi.hoisted(() => ({ + wasMoved: false, + recentPressMoved: false, + movedDuringPress: false, + attached: [] as (HTMLElement | null)[], +})); + +vi.mock('@utils/core/platform', () => ({ isMac: () => false })); + +vi.mock('@hooks/Grid/useDraggable', () => ({ + useDraggable: () => ({ + ref: (node: HTMLElement | null) => hookState.attached.push(node), + dx: 0, + dy: 0, + wasMoved: hookState.wasMoved, + isDragging: false, + recentPressMovedRef: { current: hookState.recentPressMoved }, + }), +})); + +vi.mock('@hooks/Grid/useSelectionDrag', () => ({ + useSelectionDrag: () => ({ + handlePointerDown: () => {}, + movedDuringPressRef: { current: hookState.movedDuringPress }, + pressMovedRef: { current: false }, + }), +})); + +vi.mock('@hooks/Grid/useSmartGuidesElements', () => ({ + useSmartGuidesElements: () => ({ getOtherElements: () => [] }), +})); + +const handlers = { + onClick: vi.fn(), + onDoubleClick: vi.fn(), + onCtrlClick: vi.fn(), + onShiftClick: vi.fn(), + onEraserClick: vi.fn(), + onContextMenu: vi.fn(), + setReferenceRef: vi.fn(), +}; + +let container: HTMLDivElement; +let root: Root; + +const Host = ({ + isSelected = false, + activeTool, + isViewportTransforming = false, +}: { + isSelected?: boolean; + activeTool?: string; + isViewportTransforming?: boolean; +}) => { + const { attachRef, handleClick, handleDoubleClick, handleContextMenu } = + useGridItemInteraction({ + index: 0, + elementId: 'el-1', + dx: 0, + dy: 0, + elementWidth: 60, + elementHeight: 60, + isSelected, + selectedElements: [], + zoom: 1, + panX: 0, + panY: 0, + activeTool, + isViewportTransforming, + onPositionChange: () => {}, + ...handlers, + }); + return ( +
+ ); +}; + +const render = (props: Parameters[0] = {}) => { + act(() => root.render()); + return container.querySelector('[data-testid="host"]')!; +}; + +const fire = (node: HTMLElement, type: string, init: MouseEventInit = {}) => { + act(() => { + node.dispatchEvent( + new MouseEvent(type, { bubbles: true, cancelable: true, ...init }), + ); + }); +}; + +describe('useGridItemInteraction', () => { + beforeEach(() => { + hookState.wasMoved = false; + hookState.recentPressMoved = false; + hookState.movedDuringPress = false; + hookState.attached = []; + Object.values(handlers).forEach((fn) => fn.mockReset()); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('평범한 클릭은 그대로 통과한다', () => { + fire(render(), 'click'); + expect(handlers.onClick).toHaveBeenCalledTimes(1); + }); + + it('드래그로 끝난 press의 trailing click은 흡수한다', () => { + hookState.wasMoved = true; + fire(render(), 'click'); + expect(handlers.onClick).not.toHaveBeenCalled(); + expect(handlers.onEraserClick).not.toHaveBeenCalled(); + }); + + it('지우개 도구에서는 클릭이 지우개로 간다', () => { + fire(render({ activeTool: 'eraser' }), 'click'); + expect(handlers.onEraserClick).toHaveBeenCalledTimes(1); + expect(handlers.onClick).not.toHaveBeenCalled(); + }); + + it('선택 모드의 수식키 클릭은 토글로, 맨클릭은 흡수된다', () => { + const node = render({ isSelected: true }); + fire(node, 'click', { ctrlKey: true }); + expect(handlers.onCtrlClick).toHaveBeenCalledTimes(1); + fire(node, 'click'); + expect(handlers.onClick).not.toHaveBeenCalled(); + }); + + it('비선택 모드의 Shift 클릭은 범위 선택으로 간다', () => { + fire(render(), 'click', { shiftKey: true }); + expect(handlers.onShiftClick).toHaveBeenCalledTimes(1); + expect(handlers.onClick).not.toHaveBeenCalled(); + }); + + it('더블클릭은 순수 더블클릭만 통과한다', () => { + fire(render(), 'dblclick'); + expect(handlers.onDoubleClick).toHaveBeenCalledTimes(1); + }); + + it('수식키·지우개·뷰포트 변환 중 더블클릭은 막는다', () => { + fire(render(), 'dblclick', { shiftKey: true }); + fire(render({ activeTool: 'eraser' }), 'dblclick'); + fire(render({ isViewportTransforming: true }), 'dblclick'); + expect(handlers.onDoubleClick).not.toHaveBeenCalled(); + }); + + it('직전 press가 드래그로 끝났으면 더블클릭을 막는다', () => { + hookState.recentPressMoved = true; + fire(render(), 'dblclick'); + expect(handlers.onDoubleClick).not.toHaveBeenCalled(); + + hookState.recentPressMoved = false; + hookState.movedDuringPress = true; + fire(render(), 'dblclick'); + expect(handlers.onDoubleClick).not.toHaveBeenCalled(); + }); + + it('컨텍스트 메뉴는 기본 동작과 전파를 막고 전달한다', () => { + fire(render(), 'contextmenu'); + expect(handlers.onContextMenu).toHaveBeenCalledTimes(1); + }); + + it('루트 부착은 draggable 다음에 참조 ref 순서로 간다', () => { + render(); + expect(hookState.attached[0]).not.toBeNull(); + expect(handlers.setReferenceRef).toHaveBeenCalledWith( + hookState.attached[0], + ); + }); + + it('선택 모드에서는 개별 draggable에 붙이지 않는다', () => { + render({ isSelected: true }); + expect(hookState.attached).toHaveLength(0); + expect(handlers.setReferenceRef).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/hooks/Grid/useGridItemInteraction.ts b/src/renderer/hooks/Grid/useGridItemInteraction.ts index cf16a7595..bbb1e0b69 100644 --- a/src/renderer/hooks/Grid/useGridItemInteraction.ts +++ b/src/renderer/hooks/Grid/useGridItemInteraction.ts @@ -212,8 +212,6 @@ export const useGridItemInteraction = ({ isDraggingOrResizing, draggable, handleSelectionDragPointerDown, - movedDuringPressRef, - pressMovedRef, handleClick, handleDoubleClick, handleContextMenu, From cd798141d26a83bf2d9778131301db9ac1b585db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 05:03:18 +0900 Subject: [PATCH 046/125] =?UTF-8?q?test:=20=ED=98=BC=ED=95=A9=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20bou?= =?UTF-8?q?nds=20=EC=A1=B0=ED=9A=8C=20=ED=82=A4=EC=99=80=20=ED=81=B4?= =?UTF-8?q?=EB=9E=A8=ED=94=84=20=EA=B2=BD=EA=B3=84=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/runtime/mixedBatchGeometry.test.ts | 77 +++++++++++++++++-- src/renderer/utils/core/clamp.test.ts | 22 ++++++ src/renderer/utils/core/clamp.ts | 5 +- 3 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 src/renderer/utils/core/clamp.test.ts diff --git a/src/renderer/editor/runtime/mixedBatchGeometry.test.ts b/src/renderer/editor/runtime/mixedBatchGeometry.test.ts index b60699879..f822f5e0a 100644 --- a/src/renderer/editor/runtime/mixedBatchGeometry.test.ts +++ b/src/renderer/editor/runtime/mixedBatchGeometry.test.ts @@ -5,6 +5,9 @@ import { useGraphItemStore } from '@stores/data/useGraphItemStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useKnobItemStore } from '@stores/data/useKnobItemStore'; import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; +import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import { useStatItemStore } from '@stores/data/useStatItemStore'; import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; @@ -67,14 +70,21 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ ) => { await meta?.prepare?.(); // 슬롯 재계획 generator를 위해 현재 스토어로 canonical base 구성 - const [keyStore, statStore, graphStore, knobStore, groupStore] = - await Promise.all([ - import('@stores/data/useKeyStore'), - import('@stores/data/useStatItemStore'), - import('@stores/data/useGraphItemStore'), - import('@stores/data/useKnobItemStore'), - import('@stores/data/useLayerGroupStore'), - ]); + const [ + keyStore, + statStore, + graphStore, + knobStore, + spriteStore, + groupStore, + ] = await Promise.all([ + import('@stores/data/useKeyStore'), + import('@stores/data/useStatItemStore'), + import('@stores/data/useGraphItemStore'), + import('@stores/data/useKnobItemStore'), + import('@stores/data/useSpriteStore'), + import('@stores/data/useLayerGroupStore'), + ]); const base = { schemaVersion: 1, keys: keyStore.useKeyStore.getState().keyMappings, @@ -82,6 +92,7 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ statPositions: statStore.useStatItemStore.getState().positions, graphPositions: graphStore.useGraphItemStore.getState().positions, knobPositions: knobStore.useKnobItemStore.getState().positions, + spritePositions: spriteStore.useSpriteStore.getState().positions, layerGroups: groupStore.useLayerGroupStore.getState().layerGroups, }; const mutation = @@ -117,6 +128,7 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ statPositions: {}, graphPositions: {}, knobPositions: {}, + spritePositions: {}, layerGroups: {}, }), })); @@ -197,6 +209,7 @@ import { commitMixedBatchGeometry } from './mixedBatchGeometry'; import type { BatchGeometryDescriptor } from './elementOps'; const KEY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const SPRITE_ID = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; const INSTANCE_ID = '10000000-0000-4000-8000-000000000001'; const FULL_ID = `plugin-a::${INSTANCE_ID}`; const SECOND_INSTANCE_ID = '10000000-0000-4000-8000-000000000002'; @@ -222,6 +235,7 @@ const seedStores = ( overrides: { keyPosition?: Partial>; plugin?: Partial; + sprite?: CanonicalReactiveSpritePosition; } = {}, ) => { const position = { @@ -235,6 +249,9 @@ const seedStores = ( positions: { '4key': [position] }, } as never); useStatItemStore.setState({ positions: {} }); + useSpriteStore.setState({ + positions: overrides.sprite ? { '4key': [overrides.sprite] } : {}, + }); useGraphItemStore.setState({ positions: {} }); useKnobItemStore.setState({ positions: {} }); useLayerGroupStore.setState({ layerGroups: {} }); @@ -306,6 +323,50 @@ describe('commitMixedBatchGeometry', () => { expect(mocks.commitBatchGeometryByIds).not.toHaveBeenCalled(); }); + // 혼합 배치의 스프라이트는 계획 key가 아니라 op id로 full bounds를 찾는다. + // 조회 키가 어긋나면 width·height 없는 부분 patch가 eager projection에 흘러든다 + it('혼합 배치의 스프라이트는 resizeSprite op과 full bounds eager를 싣는다', async () => { + seedStores({ + sprite: makeCanonicalSpritePosition({ + id: SPRITE_ID, + dx: 100, + dy: 0, + width: 80, + height: 40, + imageRect: { x: 10, y: 4, width: 40, height: 20 }, + }), + }); + + await expect( + commitMixedBatchGeometry( + { + mode: '4key', + targets: [ + { type: 'key', id: KEY_ID }, + { type: 'sprite', id: SPRITE_ID }, + ], + operation: { kind: 'align', direction: 'left' }, + }, + [FULL_ID], + ), + ).resolves.toBe(true); + + const request = mocks.gestureCommit.mock.calls[0]?.[0] as unknown as { + editorOps: Array>; + }; + expect(request.editorOps).toContainEqual({ + kind: 'resizeSprite', + id: SPRITE_ID, + bounds: { dx: 0, dy: 0, width: 80, height: 40 }, + }); + + // eager 저장소에도 full bounds가 실려야 한다 (부분 patch였다면 크기가 사라진다) + const eager = useSpriteStore.getState().positions['4key'][0]; + expect(eager).toMatchObject({ dx: 0, dy: 0, width: 80, height: 40 }); + // 이동만이라 배율 1 - 콘텐츠는 그대로다 + expect(eager.imageRect).toEqual({ x: 10, y: 4, width: 40, height: 20 }); + }); + it('desired projection은 plugin position만 바꾸고 다른 영속 필드는 보존한다', async () => { await expect( commitMixedBatchGeometry(alignDescriptor('left'), [FULL_ID]), diff --git a/src/renderer/utils/core/clamp.test.ts b/src/renderer/utils/core/clamp.test.ts new file mode 100644 index 000000000..45c925dfa --- /dev/null +++ b/src/renderer/utils/core/clamp.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { clamp } from './clamp'; + +describe('clamp', () => { + it('범위 안의 값은 그대로 둔다', () => { + expect(clamp(5, 0, 10)).toBe(5); + }); + + it('경계 밖은 가까운 경계로 접는다', () => { + expect(clamp(-3, 0, 10)).toBe(0); + expect(clamp(42, 0, 10)).toBe(10); + }); + + it('역전 범위에서는 max가 이긴다 (백엔드 클램프와 같은 순서)', () => { + expect(clamp(7, 10, 5)).toBe(5); + }); + + it('NaN은 그대로 통과한다 - 입력 검증은 호출부 몫', () => { + expect(Number.isNaN(clamp(Number.NaN, 0, 10))).toBe(true); + }); +}); diff --git a/src/renderer/utils/core/clamp.ts b/src/renderer/utils/core/clamp.ts index 503a7fa0f..b1d7745c0 100644 --- a/src/renderer/utils/core/clamp.ts +++ b/src/renderer/utils/core/clamp.ts @@ -1,4 +1,5 @@ -// 범위 고정. max를 먼저 적용해 역전 범위(min > max)에서는 min이 이긴다 - -// 백엔드 클램프도 같은 순서라 스프라이트 수치 미러가 비트 단위로 일치한다 +// 범위 고정. max를 나중에 적용하므로 역전 범위(min > max)에서는 max가 이긴다. +// 순서가 계약인 이유는 스프라이트 리사이즈가 백엔드 클램프와 비트 단위로 맞아야 하기 때문이다 +// (resizeProjection.ts 참조) export const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); From fdd7c101e7d25179e36b74fc4bb8a52b44ad9b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 16:43:39 +0900 Subject: [PATCH 047/125] =?UTF-8?q?fix:=20=EB=AC=B4=ED=9A=A8=20=EB=AC=B8?= =?UTF-8?q?=EB=B2=95=20easing=EC=9D=84=20=EB=A0=8C=EB=8D=94=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=20=EA=B3=A1=EC=84=A0=EC=9C=BC=EB=A1=9C=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../counters/overlaySpriteItem.test.tsx | 9 +++ src/renderer/utils/sprite/spriteReach.test.ts | 54 ++++++++++++++++ src/renderer/utils/sprite/spriteReach.ts | 61 +++++++++++++++++-- 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index fa7b312bd..039075ff5 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -446,4 +446,13 @@ describe('OverlaySpriteItem onPress', () => { act(() => applyEventKeyState('KeyA', true)); expect(animations).toHaveLength(0); }); + + // WAAPI는 무효 easing을 TypeError로 거부한다 - 강등 없이 넘기면 재생이 끊기고 + // 복원 콜백이 붙지 않아 자세 이미지가 그대로 고착된다 + it('무효 easing은 폴백 곡선으로 강등해 재생에 넘긴다', () => { + render(oneShotSprite({ transitionEasing: 'cubic-bezier(2, 0, 0.5, 1)' })); + act(() => applyEventKeyState('KeyA', true)); + expect(animations).toHaveLength(1); + expect(animations[0].options.easing).toBe('ease'); + }); }); diff --git a/src/renderer/utils/sprite/spriteReach.test.ts b/src/renderer/utils/sprite/spriteReach.test.ts index 1c0b80b26..72ac9fc85 100644 --- a/src/renderer/utils/sprite/spriteReach.test.ts +++ b/src/renderer/utils/sprite/spriteReach.test.ts @@ -202,4 +202,58 @@ describe('easingOvershootExtension', () => { expect(resolveSpriteRenderEasing(OVERSHOOT_EASING)).toBe(OVERSHOOT_EASING); expect(resolveSpriteRenderEasing('linear')).toBe('linear'); }); + + // WAAPI는 무효 easing에 TypeError를 던져 단발 재생을 통째로 끊는다. + // CSS transition만 보던 시절의 통과 규칙이 남으면 그 예외가 그대로 나간다 + it('문법이 무효한 easing은 폴백 곡선으로 강등된다', () => { + for (const easing of [ + 'cubic-bezier(2, 0, 0.5, 1)', + 'cubic-bezier(-1, 0, 0.5, 1)', + 'cubic-bezier(0, 0, 1.2, 1)', + 'wobble', + 'ease-in-out-back', + 'steps(0)', + 'steps(-2)', + 'steps(1, jump-none)', + 'linear(0, abc, 1)', + // 백분율 단독 스톱과 괄호 누수 - parseFloat에 맡기면 유한값으로 새어 나간다 + 'linear(0, 1%, 1)', + 'linear(0, 50%, 1)', + 'linear(0, 1))', + 'linear(0,1) junk)', + // 스톱 하나짜리 목록과 무한대 스톱 - 앞은 엔진이 거부하고 + // 뒤는 유한값으로 잘려 도달 계산이 [0, 1]로 오판한다 + 'linear(0)', + 'linear(1)', + 'linear(1e999, 0)', + 'linear(0, 1e999)', + 'linear(-1e999, 1)', + '', + ]) { + expect(resolveSpriteRenderEasing(easing)).toBe( + SPRITE_SAFE_FALLBACK_EASING, + ); + } + }); + + it('유효한 문법은 강등 대상이 아니다', () => { + for (const easing of [ + 'ease-in-out', + 'step-start', + 'steps(4)', + 'steps(4, jump-none)', + 'steps(2, end)', + 'steps(+2, end)', + 'linear(0, 0.25 75%, 1)', + 'linear(0, 0.5 25% 75%, 1)', + ' cubic-bezier(0.4, 0, 0.2, 1) ', + ]) { + expect(resolveSpriteRenderEasing(easing)).toBe(easing); + } + }); + + it('무효 easing 강등은 도달 여유를 늘리지 않는다', () => { + expect(easingOvershootExtension('cubic-bezier(2, 0, 0.5, 1)')).toBe(0); + expect(easingOvershootExtension('wobble')).toBe(0); + }); }); diff --git a/src/renderer/utils/sprite/spriteReach.ts b/src/renderer/utils/sprite/spriteReach.ts index 1558bfab2..cba201d6d 100644 --- a/src/renderer/utils/sprite/spriteReach.ts +++ b/src/renderer/utils/sprite/spriteReach.ts @@ -87,8 +87,8 @@ const cubicBezierYRange = ( }; // easing 출력(보간 진행률)의 최소·최대. -// CSS가 해석하지 못하는 문자열은 transition 선언 자체가 무효라 스냅 전환이 되고, -// 보간이 일어나지 않으므로 [0, 1]로 본다 +// 해석하지 못하는 문자열은 렌더가 폴백 곡선으로 강등하고 그 곡선도 [0, 1]이라 +// 여기서 같은 값을 돌려주면 도달 계산과 실제 전환이 어긋나지 않는다 export const easingOutputRange = ( easing: string, ): { min: number; max: number } => { @@ -129,13 +129,66 @@ const easingOvershootEpsilon = (easing: string): number => { return Math.max(max - 1, -min, 0); }; +const STEPS_FN_RE = new RegExp( + String.raw`^steps\(\s*([+-]?\d+)\s*(?:,\s*(jump-start|jump-end|jump-none|jump-both|start|end)\s*)?\)$`, + 'i', +); + +// = && ? - 백분율 단독 스톱은 무효다. +// 파싱을 parseFloat에 맡기면 `1%`나 괄호가 새는 `1)`이 유한값으로 통과한다 +const LINEAR_STOP_RE = new RegExp( + String.raw`^${CSS_NUMBER}(?:\s+${CSS_NUMBER}%){0,2}$`, +); + +// 렌더 채널 양쪽이 받아들이는 문법인지. CSS transition은 무효 문자열을 선언째 +// 버리고 넘어가지만 WAAPI는 TypeError를 던져 재생 자체가 끊기므로, 두 채널이 +// 같은 값을 쓰려면 여기서 문법을 확정해야 한다 +const isRenderableEasing = (easing: string): boolean => { + const trimmed = easing.trim(); + const lower = trimmed.toLowerCase(); + if (NON_OVERSHOOT_KEYWORDS.has(lower)) return true; + + const steps = STEPS_FN_RE.exec(trimmed); + // jump-none은 구간이 둘 이상이어야 성립한다 + if (steps) { + const count = Number(steps[1]); + if (count < 1) return false; + return count > 1 || steps[2]?.toLowerCase() !== 'jump-none'; + } + + const bezier = CUBIC_BEZIER_RE.exec(trimmed); + // x 제어점이 0~1 밖이면 무효 곡선 + if (bezier) { + const [x1, , x2] = bezier.slice(1, 5).map(Number); + return x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1; + } + + const linearFn = LINEAR_FN_RE.exec(trimmed); + if (linearFn) { + const stops = linearFn[1].split(',').map((stop) => stop.trim()); + // 스톱은 둘 이상이어야 한다 - linear(0)은 목록 최소 개수를 못 채워 무효다. + // 무한대 스톱은 엔진이 유한값으로 잘라 도달 계산과 실제 전환이 갈리므로 함께 강등 + return ( + stops.length >= 2 && + stops.every( + (stop) => + LINEAR_STOP_RE.test(stop) && Number.isFinite(Number.parseFloat(stop)), + ) + ); + } + return false; +}; + // 지나침 폭이 1 이상이면 재타깃 반복마다 지나침 위에서 다시 출발해 누적이 // 발산하고, 어떤 유한한 창 여유로도 클리핑을 막을 수 없다. 그런 easing은 -// 렌더에서 표준 곡선으로 강등해 도달 계산과 실제 전환이 같은 상한을 공유한다 +// 렌더에서 표준 곡선으로 강등해 도달 계산과 실제 전환이 같은 상한을 공유한다. +// 문법 자체가 무효인 값도 같은 곡선으로 강등한다 export const SPRITE_SAFE_FALLBACK_EASING = 'ease'; export const resolveSpriteRenderEasing = (easing: string): string => - easingOvershootEpsilon(easing) < 1 ? easing : SPRITE_SAFE_FALLBACK_EASING; + isRenderableEasing(easing) && easingOvershootEpsilon(easing) < 1 + ? easing + : SPRITE_SAFE_FALLBACK_EASING; // 전환이 목표를 지나칠 수 있는 비율. 보간값 v = a + p(b - a)에서 진행률 p가 // [0, 1]을 벗어나는 최대 폭 e를 구하고, 전환 중 재타깃이 반복되면 지나침 위에서 From 46805638ebb8814e6dafe54cfc92eabc2955c966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 16:43:59 +0900 Subject: [PATCH 048/125] =?UTF-8?q?fix:=20=ED=82=A4=20=EB=88=8C=EB=A6=BC?= =?UTF-8?q?=20edge=20=EA=B5=AC=EB=8F=85=EC=9E=90=EB=B3=84=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stores/signals/keySignals.test.ts | 55 +++++++++++++++++++ src/renderer/stores/signals/keySignals.ts | 9 ++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/renderer/stores/signals/keySignals.test.ts diff --git a/src/renderer/stores/signals/keySignals.test.ts b/src/renderer/stores/signals/keySignals.test.ts new file mode 100644 index 000000000..33ed3efda --- /dev/null +++ b/src/renderer/stores/signals/keySignals.test.ts @@ -0,0 +1,55 @@ +/** + * 실입력 DOWN edge 채널 + * - 이벤트 이력 기준 상승 전이에만 발화하고, 구독자 하나가 던져도 팬아웃이 이어진다 + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + applyEventKeyState, + resetAllKeySignals, + setKeyActive, + subscribeKeyPressEdge, +} from './keySignals'; + +describe('subscribeKeyPressEdge', () => { + afterEach(() => { + resetAllKeySignals(); + vi.restoreAllMocks(); + }); + + it('실입력 상승 전이에만 발화하고 리싱크 세팅은 무시한다', () => { + const listener = vi.fn(); + const unsubscribe = subscribeKeyPressEdge('KeyA', listener); + + setKeyActive('KeyA', true); + expect(listener).not.toHaveBeenCalled(); + + applyEventKeyState('KeyA', true); + applyEventKeyState('KeyA', true); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + applyEventKeyState('KeyA', false); + applyEventKeyState('KeyA', true); + expect(listener).toHaveBeenCalledTimes(1); + }); + + // 오버레이는 이 호출 직후에 노트 이펙트를 처리한다 - 예외가 새어 나가면 + // 잎 하나 때문에 그 키의 노트가 통째로 사라진다 + it('구독자 예외가 나머지 구독자와 호출자를 끊지 않는다', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const later = vi.fn(); + // resetAllKeySignals는 구독을 걷지 않는다 - 던지는 구독자를 남기면 다음 테스트가 오염된다 + const unsubscribes = [ + subscribeKeyPressEdge('KeyA', () => { + throw new TypeError('easing'); + }), + subscribeKeyPressEdge('KeyA', later), + ]; + + expect(() => applyEventKeyState('KeyA', true)).not.toThrow(); + expect(later).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalled(); + for (const unsubscribe of unsubscribes) unsubscribe(); + }); +}); diff --git a/src/renderer/stores/signals/keySignals.ts b/src/renderer/stores/signals/keySignals.ts index c98ce58fc..1ec1aa690 100644 --- a/src/renderer/stores/signals/keySignals.ts +++ b/src/renderer/stores/signals/keySignals.ts @@ -46,7 +46,14 @@ export function subscribeKeyPressEdge( function emitKeyPressEdge(key: string) { const listeners = pressEdgeListeners.get(key); if (!listeners) return; - for (const listener of [...listeners]) listener(); + // 구독자별 격리 - 잎 하나가 던져도 나머지 구독자와 호출자의 노트 처리가 이어진다 + for (const listener of [...listeners]) { + try { + listener(); + } catch (error) { + console.error('[keySignals] press edge listener error:', error); + } + } } // 이벤트 경로가 마지막으로 적용한 레벨 - edge 판정 전용. From 495f9d671d7462a68bbdefc02b99a753ee03acdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 16:45:04 +0900 Subject: [PATCH 049/125] =?UTF-8?q?fix:=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=9E=98=ED=8D=BC=EC=99=80=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9D=20=ED=91=9C=EB=A9=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../renderedElementContract.test.tsx | 12 +- .../overlay/counters/OverlaySpriteItem.tsx | 103 ++++++++++-------- .../counters/overlaySpriteItem.test.tsx | 30 ++++- 3 files changed, 88 insertions(+), 57 deletions(-) diff --git a/src/renderer/__tests__/renderedElementContract.test.tsx b/src/renderer/__tests__/renderedElementContract.test.tsx index 6a0f5a860..21f0d1543 100644 --- a/src/renderer/__tests__/renderedElementContract.test.tsx +++ b/src/renderer/__tests__/renderedElementContract.test.tsx @@ -340,7 +340,7 @@ describe('렌더 DOM 계약', () => { }); describe('오버레이 SpriteItem', () => { - it('루트에 data 속성과 히트 마커를 싣는다', () => { + it('래퍼가 히트 마커를, 안쪽 표면이 data 속성을 싣는다', () => { act(() => { root.render( { '[data-sprite-element="true"]', ); expect(el).not.toBeNull(); - // 다른 표시 요소와 같은 히트 참여 - 마커가 없으면 오버레이 클릭이 관통한다 - expect(el!.dataset.overlayHit).toBe('true'); + // 클래스는 위치 래퍼, 표식은 안쪽 표면 - 노브·카운터와 같은 배치라 + // 사용자 CSS의 `.클래스 [data-sprite-element]`가 성립한다 + const wrapper = host.querySelector( + '[data-overlay-hit="true"]', + ); + expect(wrapper).not.toBeNull(); + expect(wrapper!.contains(el)).toBe(true); + expect(wrapper).not.toBe(el); expect(el!.getAttribute('data-state')).toBe('idle'); }); }); diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 2272cf21e..557ec6508 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -262,7 +262,9 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ if (position.hidden) return null; - // 히트 기준은 활동 영역 박스(루트) - 에디터 선택 박스와 같은 의미이고 포즈 전환 + // 사용자 클래스는 위치 래퍼에, 표식은 안쪽 표면에 - 노브·카운터와 같은 배치라 + // 문서의 `.클래스 [data-sprite-element]` 선택자가 에디터와 오버레이에서 같이 먹는다. + // 히트 기준은 활동 영역 박스(래퍼) - 에디터 선택 박스와 같은 의미이고 포즈 전환 // 중에도 rect가 안정적이다. 이미지 박스는 transform을 따라 움직여 실측 시점에 // 따라 어긋나므로 히트 표식을 img로 옮기지 않는다 return ( @@ -277,55 +279,60 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ zIndex: position.zIndex ?? 0, pointerEvents: 'none', }} - data-sprite-element="true" data-overlay-hit="true" - data-state={!isOneShot && pressedIds.size > 0 ? 'active' : 'idle'} > - {isOneShot - ? mountOneShotImage && ( - { - const src = event.currentTarget.getAttribute('src'); - if (src) markFailed(src); - }} - /> - ) - : heldTarget && - heldImageSrc && ( - { - if (!isErrorForCurrentSrc(event.currentTarget, heldImageSrc)) - return; - markFailed(heldImageSrc); - }} - /> - )} +
0 ? 'active' : 'idle'} + > + {isOneShot + ? mountOneShotImage && ( + { + const src = event.currentTarget.getAttribute('src'); + if (src) markFailed(src); + }} + /> + ) + : heldTarget && + heldImageSrc && ( + { + if (!isErrorForCurrentSrc(event.currentTarget, heldImageSrc)) + return; + markFailed(heldImageSrc); + }} + /> + )} +
); }); diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index 039075ff5..f6d44cb60 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -77,6 +77,9 @@ describe('OverlaySpriteItem', () => { }); }; + // 위치·히트는 래퍼, 상태 표식은 안쪽 표면 (노브와 같은 배치) + const wrapperEl = () => + container.querySelector('[data-overlay-hit="true"]'); const spriteEl = () => container.querySelector('[data-sprite-element="true"]'); const imgEl = () => spriteEl()?.querySelector('img') ?? null; @@ -101,13 +104,17 @@ describe('OverlaySpriteItem', () => { const el = spriteEl(); expect(el).not.toBeNull(); expect(el?.dataset.state).toBe('idle'); - expect(el?.style.transform).toBe('translate3d(12px, 24px, 0)'); - expect(el?.style.width).toBe('300px'); - expect(el?.style.height).toBe('200px'); - expect(el?.style.zIndex).toBe('5'); - expect(el?.style.pointerEvents).toBe('none'); + // 히트 마커로 네이티브 히트 패널에 참여 - 상호작용은 패널 몫이라 pointer-events는 none 유지 - expect(el?.dataset.overlayHit).toBe('true'); + const wrapper = wrapperEl(); + expect(wrapper).not.toBeNull(); + expect(wrapper).not.toBe(el); + expect(wrapper?.contains(el!)).toBe(true); + expect(wrapper?.style.transform).toBe('translate3d(12px, 24px, 0)'); + expect(wrapper?.style.width).toBe('300px'); + expect(wrapper?.style.height).toBe('200px'); + expect(wrapper?.style.zIndex).toBe('5'); + expect(wrapper?.style.pointerEvents).toBe('none'); const img = imgEl(); expect(img?.getAttribute('src')).toBe(BASE_IMAGE); @@ -232,9 +239,20 @@ describe('OverlaySpriteItem', () => { expect(spriteEl()?.dataset.state).toBe('active'); }); + // 문서가 안내하는 선택자 형태 - 에디터에서만 먹고 오버레이에서 빗나가면 + // 사용자가 편집창에서 확인한 CSS가 실제 화면에서 조용히 사라진다 + it('문서의 클래스 한정 선택자가 오버레이에서 매치된다', () => { + render(makeSprite({ className: 'left-hand' })); + expect( + container.querySelector('.left-hand [data-sprite-element] > img'), + ).not.toBeNull(); + }); + it('hidden이면 아무것도 그리지 않는다', () => { render(makeSprite({ hidden: true })); expect(spriteEl()).toBeNull(); + // 래퍼가 남으면 보이지 않는 히트 영역이 생긴다 + expect(wrapperEl()).toBeNull(); }); }); From 64542c51dd07b9691bf94fb95aaf580e786cd7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 16:45:40 +0900 Subject: [PATCH 050/125] =?UTF-8?q?fix:=20=EB=8B=A8=EB=B0=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/counters/OverlaySpriteItem.tsx | 75 ++++++++------ .../counters/overlaySpriteItem.test.tsx | 98 +++++++++++++++++++ 2 files changed, 145 insertions(+), 28 deletions(-) diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 557ec6508..f09bba8ce 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -1,5 +1,5 @@ 'use no memo'; -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { useSignals } from '@preact/signals-react/runtime'; import { getKeySignal, @@ -73,6 +73,10 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ ); const baseImageSrc = resolveImageSource(position.baseImage); + // 실패한 기본 이미지는 "기본 이미지 없음"으로 취급한다 - 깨진 노드를 남기면 + // 투명 오버레이 위에 회색 대체 박스가 상주한다 (whileHeld 분기와 같은 규칙) + const idleBaseSrc = + baseImageSrc && !failedImageSrcs.has(baseImageSrc) ? baseImageSrc : null; // easing 해석은 정규식·베지어 극값 계산이라 렌더마다 돌리지 않는다. // 발산 easing은 도달 계산과 같은 기준으로 강등 (창 클리핑 방지) @@ -116,7 +120,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ (pose) => pose.imageOverride != null, ); const mountOneShotImage = - isOneShot && (baseImageSrc !== null || hasOverrideImage); + isOneShot && (idleBaseSrc !== null || hasOverrideImage); const imgRef = useRef(null); const playbackRef = useRef({ @@ -140,12 +144,35 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ } }; - // edge 핸들러는 구독 effect보다 자주 갱신되는 값을 ref로 읽는다 (재구독 방지) + // edge 핸들러는 구독 effect보다 자주 갱신되는 값을 ref로 읽는다 (재구독 방지). + // layout 단계 갱신이라 구독 해제 cleanup도 최신 문서·실패 집합으로 복원한다 const latestRef = useRef({ position, failedImageSrcs }); - useEffect(() => { + useLayoutEffect(() => { latestRef.current = { position, failedImageSrcs }; }); + // 직접 쓴 src·visibility를 현재 문서의 기본 이미지로 되돌린다. + // 재생 종료·취소, 구독 해제, 로드 실패가 같은 복원 규칙을 쓴다. + // excludedSrc는 방금 실패해 아직 실패 집합에 반영되지 않은 src + const restoreBaseImage = ( + el: HTMLImageElement, + excludedSrc?: string | null, + ) => { + const { position: pos, failedImageSrcs: failed } = latestRef.current; + const currentBase = resolveImageSource(pos.baseImage); + if ( + currentBase && + currentBase !== excludedSrc && + !failed.has(currentBase) + ) { + el.src = currentBase; + el.style.visibility = ''; + } else { + el.removeAttribute('src'); + el.style.visibility = 'hidden'; + } + }; + useEffect(() => { if (!isOneShot) return undefined; // cleanup 복원 대상 노드 - 같은 분기 재구독 동안은 key가 같아 안정적이고, @@ -159,21 +186,6 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ } if (canonicals.size === 0) return undefined; - // 직접 쓴 src·visibility를 현재 문서의 기본 이미지로 되돌린다. - // 재생 종료·취소와 구독 해제가 같은 복원 규칙을 쓴다 - const showLatestBaseImage = (el: HTMLImageElement) => { - const currentBase = resolveImageSource( - latestRef.current.position.baseImage, - ); - if (currentBase) { - el.src = currentBase; - el.style.visibility = ''; - } else { - el.removeAttribute('src'); - el.style.visibility = 'hidden'; - } - }; - const handleEdge = () => { const el = imgRef.current; if (!el) return; @@ -187,10 +199,10 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ } const resolved = resolveSpriteTarget(pos, pressed); + let baseSrc = resolveImageSource(pos.baseImage); + if (baseSrc && failed.has(baseSrc)) baseSrc = null; let src = resolveImageSource(resolved.imageSrc); - if (src && failed.has(src)) src = resolveImageSource(pos.baseImage); - if (src && failed.has(src)) src = null; - const baseSrc = resolveImageSource(pos.baseImage); + if (src && failed.has(src)) src = baseSrc; const playback = playbackRef.current; // 재트리거 소유권 이전 - 이전 재생의 콜백을 떼고 취소한다 @@ -209,7 +221,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ const restore = () => { if (playbackRef.current.generation !== generation) return; // 재생 중 문서가 바뀌었을 수 있어 캡처값 대신 최신 baseImage로 복원 - showLatestBaseImage(el); + restoreBaseImage(el); }; const fromCss = spriteTransformToCss(resolved.transform); @@ -256,7 +268,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ // 직접 쓴 src·visibility 잔상 제거 - React는 prop이 같으면 DOM을 다시 // 쓰지 않으므로 여기서 현재 문서 기준으로 복원한다 (전환 시에는 key가 // 노드를 갈아 끼우고, 같은 분기 안 재구독에는 이 복원이 잡는다) - if (mountedImg) showLatestBaseImage(mountedImg); + if (mountedImg) restoreBaseImage(mountedImg); }; }, [isOneShot, triggerIds, keyCanonicalMap]); @@ -294,18 +306,25 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ // 다음 모드로 새어 나가지 않게 한다 key="one-shot" ref={imgRef} - src={baseImageSrc ?? undefined} + src={idleBaseSrc ?? undefined} alt="" draggable={false} style={{ ...computeSpriteImageStyle(position, position.idleTransform), willChange: 'transform', // 기본 이미지가 없으면 재생 순간에만 보인다 - ...(baseImageSrc ? {} : { visibility: 'hidden' as const }), + ...(idleBaseSrc ? {} : { visibility: 'hidden' as const }), }} onError={(event) => { - const src = event.currentTarget.getAttribute('src'); - if (src) markFailed(src); + // 재생이 DOM에 직접 쓴 src도 실제 실패 URL과 대조한다 - + // 늦게 도착한 이전 src의 오류가 멀쩡한 이미지를 낙인찍지 않게 + const el = event.currentTarget; + const src = el.getAttribute('src'); + if (!isErrorForCurrentSrc(el, src)) return; + markFailed(src); + // 직접 쓴 src는 React prop이 그대로라 리렌더가 걷어내지 못한다. + // 재생이 끝날 때까지 깨진 이미지를 두지 않게 여기서 되돌린다 + restoreBaseImage(el, src); }} /> ) diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index f6d44cb60..a1b7b5313 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -473,4 +473,102 @@ describe('OverlaySpriteItem onPress', () => { expect(animations).toHaveLength(1); expect(animations[0].options.easing).toBe('ease'); }); + + it('실패한 기본 이미지는 idle에서 숨기고 재생 복원도 숨김으로 끝낸다', () => { + render(oneShotSprite()); + const img = imgEl()!; + expect(img.src).toContain(BASE_IMAGE); + + act(() => { + img.dispatchEvent(new Event('error', { bubbles: false })); + }); + // 깨진 노드를 남기면 투명 오버레이 위에 대체 박스가 상주한다 + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + + // 자세 이미지는 멀쩡하므로 재생은 그대로 되고, 복원만 숨김으로 간다 + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.src).toContain(OVERRIDE_IMAGE); + act(() => animations[0].onfinish?.()); + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + }); + + it('재생 중 자세 이미지가 실패하면 그 자리에서 기본 이미지로 되돌린다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + const img = imgEl()!; + expect(img.getAttribute('src')).toBe(OVERRIDE_IMAGE); + + act(() => { + img.dispatchEvent(new Event('error', { bubbles: false })); + }); + // 직접 쓴 src라 React prop이 그대로다 - 리렌더를 기다리면 재생이 끝날 때까지 + // (최대 pressDurationMs) 깨진 이미지가 남는다 + expect(imgEl()!.getAttribute('src')).toBe(BASE_IMAGE); + expect(imgEl()!.style.visibility).toBe(''); + }); + + it('기본 이미지도 실패한 상태면 자세 실패 즉시 노드를 숨긴다', () => { + render(oneShotSprite()); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.getAttribute('src')).toBe(OVERRIDE_IMAGE); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + }); + + it('자세 이미지도 실패하면 실패한 base로 폴백하지 않는다', () => { + render(oneShotSprite()); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.src).toContain(OVERRIDE_IMAGE); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', false)); + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.hasAttribute('src')).toBe(false); + expect(imgEl()!.style.visibility).toBe('hidden'); + }); + + // jsdom은 실패 로드에 currentSrc를 채우지 않아 가드가 항상 통과한다. + // 실기 엔진의 불일치를 재현해야 검증이 성립한다 + it('늦게 도착한 이전 src의 오류는 재생 중 이미지를 낙인찍지 않는다', () => { + render(oneShotSprite()); + act(() => applyEventKeyState('KeyA', true)); + const img = imgEl()!; + expect(img.getAttribute('src')).toBe(OVERRIDE_IMAGE); + + // 직전 base 요청의 오류가 자세 이미지로 갈아탄 뒤에 도착한 상황 + Object.defineProperty(img, 'currentSrc', { + value: BASE_IMAGE, + configurable: true, + }); + act(() => { + img.dispatchEvent(new Event('error', { bubbles: false })); + }); + + act(() => applyEventKeyState('KeyA', false)); + act(() => applyEventKeyState('KeyA', true)); + expect(imgEl()!.getAttribute('src')).toBe(OVERRIDE_IMAGE); + }); + + it('기본 이미지만 있고 그마저 실패하면 노드를 내린다', () => { + render(oneShotSprite({ poses: [makePose('p1', ['el-a'])] })); + act(() => { + imgEl()!.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(imgEl()).toBeNull(); + }); }); From 5aff98bbe7a258af176b67113cb940fbf8dbabeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 16:46:08 +0900 Subject: [PATCH 051/125] =?UTF-8?q?fix:=20=EC=99=B8=EB=B6=80=20canonical?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=20?= =?UTF-8?q?=EC=9E=90=EC=84=B8=20=EC=B4=88=EC=95=88=20=ED=8F=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.test.tsx | 53 +++++++++++++++++++ .../single/SingleSpritePanel.tsx | 20 +++++++ 2 files changed, 73 insertions(+) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index 17d3ca608..94600a9f4 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -1233,6 +1233,59 @@ describe('SingleSpritePanel 자세 편집', () => { ).toEqual([]); }); + it('무효 draft 중 외부에서 poses가 갈리면 초안을 버린다', async () => { + const position = spritePosition({ + poses: [ + { + contactPoint: { x: 0.5, y: 1 }, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 12, y: -6, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + // 유일한 담당 키 해제 - 커밋이 막힌 무효 draft가 이전 transform을 들고 있다 + await act(async () => triggerDropdownByText('A').click()); + await act(async () => menuOptionByText('A').click()); + mocks.patchPosition.mockClear(); + + // undo가 자세를 이전 값으로 되돌린 상황 (박스는 그대로라 리사이즈 rebase 밖) + const undone = spritePosition({ + poses: [ + { + contactPoint: { x: 0.5, y: 1 }, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(undone); + render(undone); + + // 초안이 폐기돼 canonical이 그대로 보인다 - 드롭다운은 열린 채로 남아 있다 + expect(openListbox()).not.toBeNull(); + await act(async () => menuOptionByText('S').click()); + + // 되돌린 canonical 위에서 커밋된다 - 초안의 옛 배열이 되살아나지 않는다 + const committed = mocks.patchPosition.mock.calls.at(-1)?.[2] as { + poses: Array<{ transform: unknown; triggers: string[] }>; + }; + expect(committed.poses[0].triggers).toEqual([KEY_ID_A, KEY_ID_B]); + expect(committed.poses[0].transform).toEqual({ + x: 0, + y: 0, + rotation: 0, + scale: 1, + }); + }); + it('자세 팝업이 열린 채 리사이즈가 착지하면 진행 중 편집 게스처를 취소한다', async () => { const position = spritePosition({ imageRect: { x: 4, y: 8, width: 96, height: 64 }, diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index b24221f24..89a37cf4a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -7,6 +7,7 @@ import { useKeyStore } from '@stores/data/useKeyStore'; import { useSpriteStore } from '@stores/data/useSpriteStore'; import { spriteItemsApi } from '@api/modules/itemsApi'; import { clamp } from '@utils/core/clamp'; +import { stableStringify } from '@utils/core/stableStringify'; import { pickValidatedImagePath } from '@utils/core/pickValidatedImage'; import { anchorToPercent, percentToAnchor } from '@utils/sprite/spriteGeometry'; import { isHTMLElementNode } from '@utils/dom/isElementNode'; @@ -195,6 +196,7 @@ export const SingleSpritePanel: React.FC = ({ } | null>(null); if (lastPositionSnapshot !== canonicalPosition) { setLastPositionSnapshot(canonicalPosition); + let draftRebased = false; if ( lastPositionSnapshot.id === canonicalPosition.id && (lastPositionSnapshot.width !== canonicalPosition.width || @@ -223,6 +225,7 @@ export const SingleSpritePanel: React.FC = ({ nextBounds, ).poses, }); + draftRebased = true; } // 편집 팝업(자세·이미지 설정)이 열린 채 착지하면 세대를 올린다 - // 팝업 리마운트가 진행 중 스크럽 세션을 취소로 닫고, 아래 effect가 @@ -235,6 +238,23 @@ export const SingleSpritePanel: React.FC = ({ } } } + // 외부에서 poses가 갈리면(undo·redo·다른 창 편집) 초안을 버린다. + // 내 커밋 착지는 canonical이 초안과 같아 여기 걸리지 않으므로, 무효 초안이 + // 남아 있다가 다시 유효해지는 순간 이전 배열을 통째로 되살리는 것만 막는다. + // 폐기는 되돌릴 수 없어 키 순서에 둔감한 비교를 쓴다 - 백엔드 문서 교체로 + // 직렬화 순서만 바뀐 것을 외부 변경으로 오판하면 편집 중인 값이 사라진다 + if ( + !draftRebased && + posesDraft && + posesDraft.id === canonicalPosition.id && + lastPositionSnapshot.id === canonicalPosition.id && + stableStringify(lastPositionSnapshot.poses) !== + stableStringify(canonicalPosition.poses) && + stableStringify(canonicalPosition.poses) !== + stableStringify(posesDraft.poses) + ) { + setPosesDraft(null); + } } // 게스처 취소는 렌더 밖에서 - cancel은 활성 게스처가 없으면 no-op이라 From 32498da6821d151f7ce8f567d284d5cef95c0069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 18:14:45 +0900 Subject: [PATCH 052/125] =?UTF-8?q?fix:=20=EC=97=94=EC=A7=84=EC=9D=B4=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=ED=95=9C=20easing=EC=9D=98=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=20=ED=8F=B4=EB=B0=B1=EA=B3=BC=20=EC=9E=90=EC=84=B8=20?= =?UTF-8?q?transform=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/counters/OverlaySpriteItem.tsx | 112 ++++++++++++------ .../counters/overlaySpriteItem.test.tsx | 102 +++++++++++++++- 2 files changed, 176 insertions(+), 38 deletions(-) diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index f09bba8ce..cf9db46cf 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -27,6 +27,24 @@ interface OverlaySpriteItemProps { keyCanonicalMap: ReadonlyMap; } +// 엔진이 거부한 easing - 문법이 유효해도 구형 WebKit(macOS 11)이나 OBS의 CEF는 +// linear()를 모른다. 한 번 거부당하면 다시 시도하지 않는다 +const rejectedEasings = new Set(); + +// 폴백 재생이 직접 쓴 transform을 문서의 idle로 되돌린다. +// 기본 모드는 비우면 CSS 변수 채널이 받고, 인라인 우선 모드는 React가 쓴 것과 +// 같은 선언을 되돌린다. 호출부가 항상 최신 문서를 넘겨야 재생 중 idleTransform· +// 인라인 우선이 바뀌어도 캡처해 둔 옛 값을 다시 덮지 않는다 +const restoreIdleTransform = ( + el: HTMLImageElement, + position: CanonicalReactiveSpritePosition, +) => { + el.style.transform = + position.useInlineStyles === true + ? spriteTransformToCss(position.idleTransform) + : ''; +}; + // 단발(onPress) 재생 상태 - 재트리거 소유권 검증용 세대 포함. // 늦게 도착한 이전 재생의 복원 콜백이 새 재생의 이미지를 덮지 못하게 한다 interface OneShotPlayback { @@ -128,22 +146,6 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ animation: null, timer: null, }); - // 진행 중 재생 정리 - 세대 무효화로 늦은 콜백까지 차단 (멱등) - const stopPlayback = () => { - const playback = playbackRef.current; - playback.generation += 1; - if (playback.animation) { - playback.animation.onfinish = null; - playback.animation.oncancel = null; - playback.animation.cancel(); - playback.animation = null; - } - if (playback.timer !== null) { - clearTimeout(playback.timer); - playback.timer = null; - } - }; - // edge 핸들러는 구독 effect보다 자주 갱신되는 값을 ref로 읽는다 (재구독 방지). // layout 단계 갱신이라 구독 해제 cleanup도 최신 문서·실패 집합으로 복원한다 const latestRef = useRef({ position, failedImageSrcs }); @@ -173,6 +175,26 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ } }; + // 진행 중 재생 정리 - 세대 무효화로 늦은 콜백까지 차단 (멱등). + // 노드는 호출부가 넘긴다 - 언마운트 뒤 정리에서는 imgRef가 이미 비어 있다 + const stopPlayback = (el: HTMLImageElement | null) => { + const playback = playbackRef.current; + playback.generation += 1; + if (playback.animation) { + playback.animation.onfinish = null; + playback.animation.oncancel = null; + playback.animation.cancel(); + playback.animation = null; + } + if (playback.timer !== null) { + clearTimeout(playback.timer); + playback.timer = null; + // 폴백이 쓴 transform은 React prop이 그대로라 리렌더가 걷어내지 못한다. + // 취소도 정상 만료와 같은 복원을 거쳐야 자세 각도가 남지 않는다 + if (el) restoreIdleTransform(el, latestRef.current.position); + } + }; + useEffect(() => { if (!isOneShot) return undefined; // cleanup 복원 대상 노드 - 같은 분기 재구독 동안은 key가 같아 안정적이고, @@ -206,7 +228,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ const playback = playbackRef.current; // 재트리거 소유권 이전 - 이전 재생의 콜백을 떼고 취소한다 - stopPlayback(); + stopPlayback(el); const generation = playback.generation; if (src) { @@ -226,17 +248,40 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ const fromCss = spriteTransformToCss(resolved.transform); const toCss = spriteTransformToCss(pos.idleTransform); - if (typeof el.animate === 'function') { - // 자세에서 시작해 idle로 돌아오는 한 방향 재생 - 스냅 타격 + 이즈 복귀. - // fill: none이라 종료 후에는 CSS 변수 채널(idle)이 그대로 복귀값이다 - const animation = el.animate( - [{ transform: fromCss }, { transform: toCss }], - { - duration: pos.pressDurationMs, - easing: resolveSpriteRenderEasing(pos.transitionEasing), - fill: 'none', - }, - ); + // WAAPI 부재·거부 공용 폴백 - 자세로 스냅했다가 시간 후 복귀 (보간 없음) + const snapBack = () => { + el.style.transform = fromCss; + playback.timer = window.setTimeout(() => { + if (playbackRef.current.generation !== generation) return; + playbackRef.current.timer = null; + restoreIdleTransform(el, latestRef.current.position); + restore(); + }, pos.pressDurationMs); + }; + + const easing = resolveSpriteRenderEasing(pos.transitionEasing); + let animation: Animation | null = null; + if (typeof el.animate === 'function' && !rejectedEasings.has(easing)) { + try { + // 자세에서 시작해 idle로 돌아오는 한 방향 재생 - 스냅 타격 + 이즈 복귀. + // fill: none이라 종료 후에는 CSS 변수 채널(idle)이 그대로 복귀값이다 + animation = el.animate( + [{ transform: fromCss }, { transform: toCss }], + { + duration: pos.pressDurationMs, + easing, + fill: 'none', + }, + ); + } catch (error) { + // 문법 게이트는 엔진의 지원 여부까지는 알 수 없다. 여기서 던지면 + // 이미 자세 이미지로 바꾼 뒤라 복원 없이는 그대로 고착된다 + rejectedEasings.add(easing); + console.error('[sprite] 엔진이 거부한 easing:', easing, error); + } + } + + if (animation) { const settle = () => { if (playbackRef.current.generation !== generation) return; playbackRef.current.animation = null; @@ -247,14 +292,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ animation.oncancel = settle; playback.animation = animation; } else { - // WAAPI 부재 폴백 - 자세로 스냅했다가 시간 후 복귀 (보간 없음) - el.style.transform = fromCss; - playback.timer = window.setTimeout(() => { - if (playbackRef.current.generation !== generation) return; - playbackRef.current.timer = null; - el.style.transform = ''; - restore(); - }, pos.pressDurationMs); + snapBack(); } }; @@ -264,7 +302,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ return () => { for (const unsubscribe of unsubscribes) unsubscribe(); // whileHeld 전환·트리거 교체 시 진행 중 재생을 함께 끊는다 - stopPlayback(); + stopPlayback(mountedImg); // 직접 쓴 src·visibility 잔상 제거 - React는 prop이 같으면 DOM을 다시 // 쓰지 않으므로 여기서 현재 문서 기준으로 복원한다 (전환 시에는 key가 // 노드를 갈아 끼우고, 같은 분기 안 재구독에는 이 복원이 잡는다) diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index a1b7b5313..3e7511695 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -5,7 +5,7 @@ */ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import type { SpritePose } from '@src/types/key/sprites'; @@ -564,6 +564,106 @@ describe('OverlaySpriteItem onPress', () => { expect(imgEl()!.getAttribute('src')).toBe(OVERRIDE_IMAGE); }); + // 문법 게이트는 엔진 지원까지 알 수 없다 - macOS 11 WebKit과 OBS의 CEF는 + // linear()를 거부한다. 던지는 자리가 src 교체 뒤라 복원이 없으면 고착된다. + // 거부 easing은 모듈 수준에 기억되므로 케이스마다 다른 값을 쓴다 + const rejectAnimate = () => { + proto.animate = function animate() { + throw new TypeError('unsupported easing'); + }; + }; + + it('엔진이 easing을 거부하면 스냅 폴백으로 재생하고 복원한다', () => { + vi.useFakeTimers(); + rejectAnimate(); + render(oneShotSprite({ transitionEasing: 'linear(0, 0.25 25%, 1)' })); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + expect(img.style.transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + + act(() => vi.advanceTimersByTime(300)); + // 기본 모드는 transform을 비워야 CSS 변수 채널이 idle을 되찾는다 + expect(img.style.transform).toBe(''); + expect(img.src).toContain(BASE_IMAGE); + vi.useRealTimers(); + }); + + it('인라인 우선 모드의 스냅 폴백은 idle transform을 직접 되돌린다', () => { + vi.useFakeTimers(); + rejectAnimate(); + render( + oneShotSprite({ + useInlineStyles: true, + transitionEasing: 'linear(0, 0.75 75%, 1)', + }), + ); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + act(() => vi.advanceTimersByTime(300)); + // 비우면 React가 인라인으로 쓴 idle transform까지 사라진다 (prop 동일이라 복구 없음) + expect(img.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + vi.useRealTimers(); + }); + + it('폴백 재생 중 재구독되면 자세 transform도 함께 걷는다', () => { + vi.useFakeTimers(); + rejectAnimate(); + const sprite = oneShotSprite({ + useInlineStyles: true, + transitionEasing: 'linear(0, 0.4 40%, 1)', + }); + render(sprite); + const img = imgEl()!; + + act(() => applyEventKeyState('KeyA', true)); + expect(img.style.transform).toBe( + 'translate(10px, -6px) rotate(15deg) scale(1.2)', + ); + + // 자세 목록 교체로 구독 effect가 다시 돈다 - 타이머만 지우면 각도가 남는다 + render({ + ...sprite, + poses: [ + makePose('p1', ['el-a'], { imageOverride: OVERRIDE_IMAGE }), + makePose('p2', ['el-a'], { imageOverride: OVERRIDE_IMAGE }), + ], + }); + expect(imgEl()!.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', + ); + vi.useRealTimers(); + }); + + it('폴백 만료는 캡처값이 아니라 최신 idle transform으로 되돌린다', () => { + vi.useFakeTimers(); + rejectAnimate(); + const sprite = oneShotSprite({ + useInlineStyles: true, + transitionEasing: 'linear(0, 0.6 60%, 1)', + }); + render(sprite); + + act(() => applyEventKeyState('KeyA', true)); + // 재생 중 idle이 바뀐다 - React가 최신 값을 쓴 뒤 타이머가 옛 값을 덮으면 안 된다 + render({ + ...sprite, + idleTransform: { x: 4, y: 8, rotation: 30, scale: 2 }, + }); + + act(() => vi.advanceTimersByTime(300)); + expect(imgEl()!.style.transform).toBe( + 'translate(4px, 8px) rotate(30deg) scale(2)', + ); + vi.useRealTimers(); + }); + it('기본 이미지만 있고 그마저 실패하면 노드를 내린다', () => { render(oneShotSprite({ poses: [makePose('p1', ['el-a'])] })); act(() => { From b1487aff191f46f0eb8a63b32377116b9f08201c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 18:15:06 +0900 Subject: [PATCH 053/125] =?UTF-8?q?fix:=20=EC=9E=90=EC=84=B8=20=EA=B8=B0?= =?UTF-8?q?=EC=A6=88=EB=AA=A8=20=EB=93=9C=EB=9E=98=EA=B7=B8=EC=9D=98=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EA=B6=8C=20=ED=99=95=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/handles/SpritePoseGizmo.test.tsx | 41 +++++++++++++++++++ .../main/Grid/handles/SpritePoseGizmo.tsx | 20 ++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx index 689c5ef99..17d50cd3b 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx @@ -180,6 +180,47 @@ describe('SpritePoseGizmo', () => { expect(session.commit).not.toHaveBeenCalled(); }); + // 리사이즈 착지·undo는 세션을 유지한 채 세대만 올린다. 이때 대기 중이던 마지막 + // move를 flush하면 패널이 이미 닫아둔 preview 제스처가 다시 열리고, 커밋만 + // 생략되어 낡은 세션이 남는다 + it('소유권이 무효화되면 대기 move를 버리고 커밋 없이 취소한다', () => { + const session = makeSession(); + render(session); + + pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); + pointer('pointermove', window, { clientX: 200, clientY: 50 }); + act(() => + useSpritePoseGizmoStore.getState().invalidateOwnership('sprite-1'), + ); + (session.preview as ReturnType).mockClear(); + + pointer('pointerup', window, { clientX: 200, clientY: 50 }); + + expect(session.preview).not.toHaveBeenCalled(); + expect(session.commit).not.toHaveBeenCalled(); + expect(session.cancel).toHaveBeenCalledTimes(1); + // 락 반환까지 포기 경로와 동일해야 한다 + expect(tryAcquireDragSession()).toBe(true); + releaseDragSession(); + }); + + it('소유권 무효화 뒤의 move는 preview를 되열지 않는다', async () => { + const session = makeSession(); + render(session); + + pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); + act(() => + useSpritePoseGizmoStore.getState().invalidateOwnership('sprite-1'), + ); + + pointer('pointermove', window, { clientX: 200, clientY: 50 }); + // rAF 스케줄러가 타이머로 강등돼 있어 한 틱 뒤에 콜백이 돈다 + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + expect(session.preview).not.toHaveBeenCalled(); + }); + it('Alt 드래그는 transform 대신 핀 위치를 커밋한다', () => { const session = makeSession(); render(session); diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx index 6b2911fee..4fb80ad62 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -180,6 +180,8 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { const drag = dragRef.current; const live = sessionRef.current; if (!drag || !live) return; + // 소유권이 갈린 뒤의 move는 이미 취소된 preview를 되열 뿐이라 버린다 + if (!ownsSession()) return; const target = clientToElementLocal(event.clientX, event.clientY); if (!target) return; @@ -236,14 +238,18 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { if (!drag) return; // 다른 포인터의 up이 첫 드래그를 커밋하지 못하게 한다 (멀티터치) if (event.pointerId !== drag.pointerId) return; + // 소유권을 잃었으면 커밋도 flush도 하지 않고 포기 경로로 간다 - + // 대기 move를 flush하면 리사이즈 착지가 닫아둔 preview가 다시 열린다 + if (!ownsSession()) { + cancelActiveDrag(); + return; + } // 같은 프레임의 마지막 move가 rAF에 남아 있으면 커밋 전에 반영한다 moveSchedulerRef.current?.flush(); - if (ownerGenerationRef.current === generationNow()) { - if (drag.mode === 'aim' && lastSolvedRef.current) { - drag.session.commit(lastSolvedRef.current); - } else if (drag.mode === 'pin' && lastPinRef.current) { - drag.session.commitContactPoint(lastPinRef.current); - } + if (drag.mode === 'aim' && lastSolvedRef.current) { + drag.session.commit(lastSolvedRef.current); + } else if (drag.mode === 'pin' && lastPinRef.current) { + drag.session.commitContactPoint(lastPinRef.current); } finishDrag(); }; @@ -257,6 +263,8 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { }; const generationNow = () => useSpritePoseGizmoStore.getState().generation; + // 드래그 시작 때 잡은 세대가 아직 유효한지 - preview·커밋의 공통 전제 + const ownsSession = () => ownerGenerationRef.current === generationNow(); const handleWindowMove = (event: PointerEvent) => { if (event.pointerId !== dragRef.current?.pointerId) return; From 6a3ca48c9966488c87c918750962c7f7fb282034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 18:15:10 +0900 Subject: [PATCH 054/125] =?UTF-8?q?docs:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20easing=20=ED=99=94=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EC=A4=80=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/utils/sprite/spriteReach.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/utils/sprite/spriteReach.ts b/src/renderer/utils/sprite/spriteReach.ts index cba201d6d..7a7743a43 100644 --- a/src/renderer/utils/sprite/spriteReach.ts +++ b/src/renderer/utils/sprite/spriteReach.ts @@ -140,9 +140,15 @@ const LINEAR_STOP_RE = new RegExp( String.raw`^${CSS_NUMBER}(?:\s+${CSS_NUMBER}%){0,2}$`, ); -// 렌더 채널 양쪽이 받아들이는 문법인지. CSS transition은 무효 문자열을 선언째 -// 버리고 넘어가지만 WAAPI는 TypeError를 던져 재생 자체가 끊기므로, 두 채널이 -// 같은 값을 쓰려면 여기서 문법을 확정해야 한다 +// 도달 범위를 정적으로 계산할 수 있는 문법인지. CSS transition은 무효 문자열을 +// 선언째 버리고 넘어가지만 WAAPI는 TypeError를 던져 재생이 끊기므로, 두 채널이 +// 같은 값을 쓰려면 여기서 문법을 확정해야 한다. +// 통과 = 엔진 지원 보장이 아니다. linear()는 Chrome 113·Safari 17.2부터라 +// macOS 11의 WebKit이나 구형 OBS의 CEF는 거부한다 - 그쪽은 재생 시점 폴백이 맡는다. +// 명세상 유효해도 뺀 것들 +// - calc()·var(): 출력 범위를 정적으로 못 구해 창 여유를 잡을 수 없다 +// - 위치 우선 linear 정지점(`75% 0.25`): WebKit이 거부해 프리셋이 플랫폼을 +// 넘을 때 동작이 갈린다 const isRenderableEasing = (easing: string): boolean => { const trimmed = easing.trim(); const lower = trimmed.toLowerCase(); From 620c9341d88607f4023fc50a0bbb4b92464eb2d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 18:58:27 +0900 Subject: [PATCH 055/125] =?UTF-8?q?fix:=20=EC=98=A8=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=ED=8E=B8=EC=A7=91=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=A7=88=EC=BB=A4=20=ED=86=B5=EC=9D=BC=EA=B3=BC=20?= =?UTF-8?q?=EC=9E=90=EC=84=B8=20=EA=B8=B0=EC=A6=88=EB=AA=A8=20=EB=8B=AB?= =?UTF-8?q?=ED=9E=98=20=EC=98=88=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/gradientAxisHandle.test.tsx | 4 +- .../main/Grid/handles/GradientAxisHandle.tsx | 2 +- .../Grid/handles/SpritePoseGizmo.test.tsx | 9 ++ .../main/Grid/handles/SpritePoseGizmo.tsx | 2 + ...FloatingPopup.canvasEditorOverlay.test.tsx | 98 +++++++++++++++++++ .../components/main/Modal/FloatingPopup.tsx | 17 ++-- 6 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 src/renderer/components/main/Modal/FloatingPopup.canvasEditorOverlay.test.tsx diff --git a/src/renderer/__tests__/gradientAxisHandle.test.tsx b/src/renderer/__tests__/gradientAxisHandle.test.tsx index c0a9b9fff..a31d918c9 100644 --- a/src/renderer/__tests__/gradientAxisHandle.test.tsx +++ b/src/renderer/__tests__/gradientAxisHandle.test.tsx @@ -210,7 +210,9 @@ describe('GradientAxisOverlay 드래그 로직', () => { }); expect(useGradientEditStore.getState().session).toBeNull(); - expect(host.querySelector('[data-dmn-gradient-overlay="true"]')).toBeNull(); + expect( + host.querySelector('[data-dmn-canvas-editor-overlay="true"]'), + ).toBeNull(); }); it('축을 잡고 window에서 움직이면 각도 프리뷰·커밋이 적용된다', () => { diff --git a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx index 05cb11f0d..ee0b5fff7 100644 --- a/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx +++ b/src/renderer/components/main/Grid/handles/GradientAxisHandle.tsx @@ -738,7 +738,7 @@ const GradientAxisOverlay = ({ className="absolute inset-0 pointer-events-none" // 리사이즈 핸들 위, 사이드 패널 아래에 두는 내부 편집 층 - 패널 위로 새지 않게 style={{ zIndex: 'var(--z-canvas-gradient-editor)' }} - data-dmn-gradient-overlay="true" + data-dmn-canvas-editor-overlay="true" > {/* 조작 UI 묶음 - 핸들 드래그나 피커 색 드래그 동안 흐려져 가려진 대상의 실제 색이 보인다. 값 배지는 묶음 밖이라 선명 유지 */} diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx index 17d50cd3b..bceb54653 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx @@ -114,6 +114,15 @@ describe('SpritePoseGizmo', () => { expect(el.style.top).toBe('189px'); }); + // 팝업은 body 포털이라 캔버스 노브는 바깥 클릭으로 읽힌다. 이 마커가 없으면 + // 노브를 누르는 순간 자세 팝업이 닫히고 세션이 걷혀 드래그가 즉사한다 + it('루트에 온캔버스 편집 오버레이 마커를 싣는다', () => { + render(makeSession()); + expect( + knob()!.closest('[data-dmn-canvas-editor-overlay="true"]'), + ).not.toBeNull(); + }); + it('노브 드래그는 up에서 마지막 move를 flush해 커밋한다 - 축 고정 회전 역산', () => { const session = makeSession(); render(session); diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx index 4fb80ad62..5e2fd5498 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -325,6 +325,8 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { ref={rootRef} className="absolute inset-0 pointer-events-none" style={{ zIndex: 'var(--z-canvas-gradient-editor)' }} + // 노브 조작이 자세 팝업의 바깥 클릭으로 읽히지 않게 한다 + data-dmn-canvas-editor-overlay="true" > {/* 축→핀 안내선 */} { + let host: HTMLDivElement; + let root: Root; + const onClose = vi.fn(); + + // 두 닫힘 경로 모두 예외를 알아야 한다 - PickerSurface(자세 팝업)는 + // autoClose=false라 capture 판정을 타고, 기본 팝업은 mousedown 판정을 탄다 + const renderPopup = async (autoClose: boolean) => { + await act(async () => + root.render( + + + , + ), + ); + }; + + // 캔버스 오버레이는 팝업과 무관한 자리에 렌더된다 (Grid 안, body 포털 밖) + const canvasNode = (marker?: string) => { + const overlay = document.createElement('div'); + if (marker) overlay.setAttribute(marker, 'true'); + const knob = document.createElement('div'); + overlay.appendChild(knob); + host.appendChild(overlay); + return knob; + }; + + const press = (node: Element, type: 'pointerdown' | 'mousedown') => + act(() => { + node.dispatchEvent( + type === 'pointerdown' + ? new PointerEvent(type, { bubbles: true, cancelable: true }) + : new MouseEvent(type, { bubbles: true, cancelable: true }), + ); + }); + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + onClose.mockClear(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('capture 판정에서 마커가 붙은 오버레이 조작은 팝업을 닫지 않는다', async () => { + await renderPopup(false); + + await press(canvasNode('data-dmn-canvas-editor-overlay'), 'pointerdown'); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('capture 판정에서 마커 없는 캔버스 클릭은 그대로 팝업을 닫는다', async () => { + await renderPopup(false); + + await press(canvasNode(), 'pointerdown'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('autoClose 판정도 같은 마커를 예외로 본다', async () => { + await renderPopup(true); + + await press(canvasNode('data-dmn-canvas-editor-overlay'), 'mousedown'); + expect(onClose).not.toHaveBeenCalled(); + + await press(canvasNode(), 'mousedown'); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/main/Modal/FloatingPopup.tsx b/src/renderer/components/main/Modal/FloatingPopup.tsx index afc594481..75df570f5 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.tsx @@ -401,11 +401,11 @@ const FloatingPopup = ({ isElementNode(target) && !!target.closest('[data-dmn-popup-submenu="true"]'); if (isInsideSubMenu) return; - // 온캔버스 그라데이션 핸들 조작도 팝업 편집의 연장 — 닫힘 예외 - const isInsideGradientOverlay = + // 온캔버스 편집 오버레이(그라데이션 축·자세 기즈모)는 닫힘 예외 + const isInsideCanvasEditor = isElementNode(target) && - !!target.closest('[data-dmn-gradient-overlay="true"]'); - if (isInsideGradientOverlay) return; + !!target.closest('[data-dmn-canvas-editor-overlay="true"]'); + if (isInsideCanvasEditor) return; // 위에 쌓인 팝업(자식 피커 등)은 body 포털이라 floating 내부로 인식되지 않음 if (isInsideHigherPopupLayer(refs.floating.current, target)) return; onClose(); @@ -567,11 +567,12 @@ const FloatingPopup = ({ return; } - // 온캔버스 그라데이션 핸들 조작도 팝업 편집의 연장 — 닫힘 예외 - const isInsideGradientOverlay = + // 온캔버스 편집 오버레이(그라데이션 축·자세 기즈모) 조작도 팝업 편집의 + // 연장이라 닫힘 예외 - 캔버스에 있을 뿐 팝업 밖 클릭이 아니다 + const isInsideCanvasEditor = isElementNode(target) && - !!target.closest('[data-dmn-gradient-overlay="true"]'); - if (isInsideGradientOverlay) { + !!target.closest('[data-dmn-canvas-editor-overlay="true"]'); + if (isInsideCanvasEditor) { pointerCapturedInside = false; return; } From f368f4226c28f822023347fe8b49ead66e52e32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 18:58:48 +0900 Subject: [PATCH 056/125] =?UTF-8?q?fix:=20=ED=8E=B8=EC=A7=91=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=20=EC=8A=A4=ED=94=84=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/layers/SpriteItem.render.test.tsx | 38 +++++++++++++++++++ .../main/Grid/layers/SpriteItem.tsx | 21 +++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index 5e95e921c..31d2b1ad3 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -378,4 +378,42 @@ describe('SpriteItem 자세 편집 프리뷰', () => { 'translate(1px, 2px) rotate(0deg) scale(1)', ); }); + + // 캔버스가 깨진 아이콘을 그리면 송출 화면과 미리보기가 갈린다. + // 오버레이는 같은 상황에서 base 폴백 후 이미지 없음으로 내려간다 + it('실패한 이미지는 기본 이미지로 폴백하고 둘 다 실패하면 자리표시자로 간다', () => { + const node = renderSprite( + spritePosition({ + baseImage: '/base.png', + poses: [pose('pose-1', { imageOverride: '/override.png' })], + }), + ); + const img = () => node?.querySelector('img'); + + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { imageOverride: '/override.png' }), + preferFallback: true, + }), + ); + expect(img()?.getAttribute('src')).toBe('/override.png'); + + act(() => { + img()?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(img()?.getAttribute('src')).toBe('/base.png'); + + act(() => { + img()?.dispatchEvent(new Event('error', { bubbles: false })); + }); + expect(img()).toBeNull(); + // 이미지가 없는 상태이므로 자리표시자와 상시 가이드로 함께 내려간다 + expect( + node?.querySelector('[data-sprite-placeholder="true"]'), + ).not.toBeNull(); + expect(activityGuide()?.className).not.toContain('border-transparent'); + }); }); diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index 15c9df43f..2dac64326 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { useGridItemInteraction } from '@hooks/Grid/useGridItemInteraction'; import { useSpriteEditPreview } from '@stores/grid/useSpriteEditPreviewStore'; +import { + isErrorForCurrentSrc, + useFailedImageSrcs, +} from '@hooks/overlay/useFailedImageSrcs'; import { resolveImageSource } from '@utils/core/imageSource'; import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; import { anchorPx } from '@utils/sprite/spriteGeometry'; @@ -72,9 +76,20 @@ const SpriteItem = ({ const showPivotMarker = editPreview?.kind === 'pivot'; const pivotMarkerPx = anchorPx(position.imageRect, position.pivot); - const imageSrc = resolveImageSource( + // 유실 이미지를 그대로 두면 캔버스에 깨진 아이콘이 박히고, 송출 화면(오버레이)은 + // 폴백을 그려 미리보기가 결과와 어긋난다. 폴백 규칙은 오버레이와 같다. + // 훅 인자는 저장된 경로로 고정 - 편집 중 draft override를 넣으면 스크럽마다 + // 키가 바뀌어 실패 집합이 초기화된다 + const { failedImageSrcs, markFailed } = useFailedImageSrcs( + position.baseImage, + ...position.poses.map((pose) => pose.imageOverride), + ); + const baseSrc = resolveImageSource(position.baseImage); + const fallbackSrc = baseSrc && !failedImageSrcs.has(baseSrc) ? baseSrc : null; + let imageSrc = resolveImageSource( previewPose?.imageOverride ?? position.baseImage, ); + if (imageSrc && failedImageSrcs.has(imageSrc)) imageSrc = fallbackSrc; const { isSelectionMode, @@ -164,6 +179,10 @@ const SpriteItem = ({ pointerEvents: 'none', userSelect: 'none', }} + onError={(event) => { + if (!isErrorForCurrentSrc(event.currentTarget, imageSrc)) return; + markFailed(imageSrc); + }} /> ) : (
Date: Tue, 1 Sep 2026 22:19:47 +0900 Subject: [PATCH 057/125] =?UTF-8?q?fix:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=90=EC=84=B8=20ID=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=92=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=8A=B9?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/editor/page.mdx | 17 +- docs/content/en/api-reference/keys/page.mdx | 7 +- docs/content/ko/api-reference/editor/page.mdx | 16 +- docs/content/ko/api-reference/keys/page.mdx | 6 +- src-tauri/src/state/native_element_id.rs | 215 +++++++++++++++++- 5 files changed, 237 insertions(+), 24 deletions(-) diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index c40252a0b..f3daa1de9 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -58,12 +58,15 @@ that top-level collection. It is not an item-level diff. Every element in the position collections (`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`, `spritePositions`) carries a stable `id` (UUID) assigned and owned by the app. Treat it as opaque: echo back the value - you read and never invent one. A written element with a missing or unknown - `id` gets a fresh one from the backend. If the same `id` appears more than - once, for example when you copy an element you read and append it, the element - in its original slot keeps the identity and the remaining copies get fresh - ones. The `poseId` carried by each pose of a sprite element follows the same - rules. See the [Keys API](/docs/api-reference/keys) for the identity rules. + you read and never invent one. A written element whose `id` is missing or + unknown inherits the `id` of an existing element whose remaining fields match; + with no match, the backend assigns a fresh one. Omitting an `id` does not + guarantee a fresh identity, and editing an element's values while omitting its + `id` may give it one. If the same `id` appears more than once, for example + when you copy an element you read and append it, the element in its original + slot keeps the identity and the remaining copies get fresh ones. The `poseId` + carried by each pose of a sprite element follows the same rules. See the [Keys + API](/docs/api-reference/keys) for the identity rules. ## Sprite Position Types @@ -71,7 +74,7 @@ that top-level collection. It is not an item-level diff. Reactive sprites are image layers that switch poses while their trigger keys are held. Unlike key elements they are not index-coupled to key mappings; each pose references the responsible key element ids directly. Documents you read -always carry `id` and `poseId`; writes may omit both to receive fresh ones. +always carry `id` and `poseId`; echo both back when you write. The collection and value limits noted below are enforced when you commit; documents you read echo stored data as is. diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index bb02b4766..09440b8cc 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -399,8 +399,11 @@ interface ElementShadowSpec { Every element position (`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`, `spritePositions`) carries a stable `id`. Treat it as opaque: echo back the value -you read and never invent one. A write whose `id` is missing or unknown gets a -fresh one assigned by the backend. When the same `id` appears more than once, +you read and never invent one. A write whose `id` is missing or unknown +inherits the `id` of an existing element whose remaining fields match; with no +match, the backend assigns a fresh one. Omitting an `id` does not guarantee a +fresh identity, and editing an element's values while omitting its `id` may +give it one. When the same `id` appears more than once, for example when you copy an element you read and append it, the element in its original slot keeps the identity and the remaining copies get fresh ones. Loading a full preset re-issues every `id`; a tab preset re-issues only the diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index 3e5d8323c..73f6a6df3 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -57,19 +57,21 @@ type EditorPatchV1 = { 위치 컬렉션(`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`, `spritePositions`)의 모든 요소는 앱이 발급·소유하는 안정 `id`(UUID)를 가집니다. 불투명 값으로 다루세요. 읽은 값을 그대로 되돌려 보내고, - 직접 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 백엔드가 새 값을 - 발급합니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 여러 번 - 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다. - 스프라이트 요소의 각 자세가 가진 `poseId`도 같은 규칙을 따릅니다. 신원 규칙은 - [Keys API](/docs/api-reference/keys)를 참고하세요. + 직접 만들지 마세요. `id`가 없거나 미확인인 요소를 쓰면 나머지 필드가 같은 기존 + 요소의 신원을 승계합니다. 맞는 항목이 없으면 백엔드가 새 값을 발급합니다. + `id`를 빼는 것이 새 신원을 보장하지는 않고, 값을 바꾸면서 `id`까지 빼면 새 + 신원이 될 수 있습니다. 읽은 요소를 복사해 배열에 덧붙이는 경우처럼 같은 `id`가 + 여러 번 나오면, 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 + 받습니다. 스프라이트 요소의 각 자세가 가진 `poseId`도 같은 규칙을 따릅니다. + 신원 규칙은 [Keys API](/docs/api-reference/keys)를 참고하세요. ## 스프라이트 위치 타입 반응형 스프라이트는 담당 키를 누르고 있는 동안 자세가 바뀌는 이미지 레이어입니다. 키 요소와 달리 키 매핑에 인덱스로 결합하지 않고, 자세마다 담당 키 요소 id를 직접 -참조합니다. 읽은 문서에는 `id`와 `poseId`가 항상 실려 있고, 쓸 때는 둘 다 생략해 -새 값을 발급받을 수 있습니다. 아래 표기된 개수·값 상한은 커밋 시점에 +참조합니다. 읽은 문서에는 `id`와 `poseId`가 항상 실려 있습니다. 쓸 때는 둘 다 +그대로 돌려보내세요. 아래 표기된 개수·값 상한은 커밋 시점에 검증하고, 읽은 문서는 저장된 데이터를 그대로 되돌려줍니다. ```typescript diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 6356efb8e..795e51fb8 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -189,8 +189,10 @@ interface ElementShadowSpec { 모든 요소 위치(`keyPositions`, `statPositions`, `graphPositions`, `knobPositions`, `spritePositions`)는 안정 `id`를 가집니다. 불투명 값으로 다루세요. 읽은 값을 -그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 -백엔드가 새 값을 발급합니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해 +그대로 되돌려 보내고, 직접 만들지 마세요. `id`가 없거나 미확인인 쓰기는 나머지 +필드가 같은 기존 요소의 신원을 승계합니다. 맞는 항목이 없으면 백엔드가 +새 값을 발급합니다. `id`를 빼는 것이 새 신원을 보장하지는 않고, 값을 바꾸면서 +`id`까지 빼면 새 신원이 될 수 있습니다. 같은 `id`가 여러 번 나오면(읽은 요소를 복사해 덧붙이는 경우처럼) 원래 자리의 요소가 신원을 지키고 나머지 사본이 새 값을 받습니다. 전체 프리셋을 불러오면 모든 `id`가 재발급되고, 탭 프리셋은 그 프리셋이 위치를 준 컬렉션만 재발급됩니다. 재정렬을 가로질러 요소를 추적할 diff --git a/src-tauri/src/state/native_element_id.rs b/src-tauri/src/state/native_element_id.rs index f0c86937e..5f30deaa6 100644 --- a/src-tauri/src/state/native_element_id.rs +++ b/src-tauri/src/state/native_element_id.rs @@ -6,7 +6,7 @@ use crate::{ errors::EditorCommitError, models::{ AppStoreData, EditorDocumentV1, EditorPatchV1, GraphPosition, KeyMappings, KeyPosition, - KeySlot, KnobPosition, ReactiveSpritePosition, StatPosition, + KeySlot, KnobPosition, ReactiveSpritePosition, SpritePose, StatPosition, EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_SCHEMA_VERSION, }, }; @@ -755,15 +755,33 @@ fn adapt_v1_sprite_pose_ids( for sprite in sprites { let current_sprite = current_sprites .and_then(|sprites| sprites.iter().find(|current| current.id == sprite.id)); - for (pose_index, pose) in sprite.poses.iter_mut().enumerate() { + let normalized_current = current_sprite + .map(|sprite| { + sprite + .poses + .iter() + .map(sprite_pose_value_without_id) + .collect::>() + }) + .unwrap_or_default(); + for pose in &mut sprite.poses { if !pose.pose_id.is_empty() { consumed_current_ids.insert(pose.pose_id.clone()); continue; } - let inherited = current_sprite - .and_then(|sprite| sprite.poses.get(pose_index)) - .map(|pose| pose.pose_id.clone()) - .filter(|id| is_valid_element_id(id) && !consumed_current_ids.contains(id)); + let target = sprite_pose_value_without_id(pose); + let inherited = current_sprite.and_then(|sprite| { + sprite + .poses + .iter() + .zip(&normalized_current) + .find(|(current_pose, normalized)| { + is_valid_element_id(¤t_pose.pose_id) + && !consumed_current_ids.contains(¤t_pose.pose_id) + && **normalized == target + }) + .map(|(current_pose, _)| current_pose.pose_id.clone()) + }); if let Some(id) = inherited { consumed_current_ids.insert(id.clone()); pose.pose_id = id; @@ -775,6 +793,13 @@ fn adapt_v1_sprite_pose_ids( } } +fn sprite_pose_value_without_id(pose: &SpritePose) -> SpritePose { + let mut copy = pose.clone(); + copy.pose_id.clear(); + copy.normalize_triggers(); + copy +} + /// id를 비운 값 사본. 비교마다 양쪽을 clone하지 않도록, 스캔 전에 한 번씩만 /// 만들어 재사용한다 (승계 웨이브의 순서·우선순위는 그대로 유지) fn value_without_id(element: &T) -> T { @@ -1374,6 +1399,36 @@ mod tests { .unwrap() } + fn sprite_pose(trigger: u128, transform_x: f64) -> SpritePose { + let mut pose = SpritePose { + triggers: vec![Uuid::from_u128(trigger).to_string()], + ..SpritePose::default() + }; + pose.transform.x = transform_x; + pose + } + + fn store_with_sprites(sprites: Vec) -> AppStoreData { + let mut store = AppStoreData { + sprite_positions: HashMap::from([("mode".to_string(), sprites)]), + ..AppStoreData::default() + }; + rekey_store_element_ids(&mut store); + store + } + + fn prepare_v1_sprite_patch( + store: &AppStoreData, + sprites: Vec, + ) -> Vec { + let mut patch = EditorPatchV1 { + sprite_positions: Some(HashMap::from([("mode".to_string(), sprites)])), + ..EditorPatchV1::default() + }; + prepare_commit_patch_element_ids(store, &mut patch).unwrap(); + patch.sprite_positions.unwrap().remove("mode").unwrap() + } + #[test] fn backfill_assigns_globally_unique_sprite_and_pose_ids() { let duplicate = uuid::Uuid::new_v4().to_string(); @@ -1570,6 +1625,154 @@ mod tests { ); } + #[test] + fn v1_idless_sprite_pose_reorder_inherits_ids_by_value() { + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0), sprite_pose(20, 2.0)], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_ids = original + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + let mut candidate = original.clone(); + candidate.poses.swap(0, 1); + for pose in &mut candidate.poses { + pose.pose_id.clear(); + } + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + + assert_eq!(assigned[0].poses[0].pose_id, original_ids[1]); + assert_eq!(assigned[0].poses[1].pose_id, original_ids[0]); + } + + #[test] + fn v1_idless_sprite_pose_value_change_gets_fresh_id() { + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0)], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_pose_id = original.poses[0].pose_id.clone(); + let mut candidate = original.clone(); + candidate.poses[0].pose_id.clear(); + candidate.poses[0].transform.x = 9.0; + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + let assigned_pose_id = &assigned[0].poses[0].pose_id; + + assert_ne!(assigned_pose_id, &original_pose_id); + assert!(is_valid_element_id(assigned_pose_id)); + } + + #[test] + fn v1_idless_sprite_pose_normalizes_triggers_for_value_matching() { + let trigger_a = Uuid::from_u128(10).to_string(); + let trigger_b = Uuid::from_u128(20).to_string(); + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![SpritePose { + triggers: vec![trigger_a.clone(), trigger_b.clone()], + ..SpritePose::default() + }], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_pose_id = original.poses[0].pose_id.clone(); + let mut candidate = original.clone(); + candidate.poses[0].pose_id.clear(); + candidate.poses[0].triggers = vec![trigger_b, trigger_a.clone(), trigger_a]; + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + + assert_eq!(assigned[0].poses[0].pose_id, original_pose_id); + } + + #[test] + fn v1_idless_sprite_pose_does_not_inherit_from_another_sprite() { + let store = store_with_sprites(vec![ + ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0)], + ..ReactiveSpritePosition::default() + }, + ReactiveSpritePosition { + poses: vec![sprite_pose(20, 2.0)], + ..ReactiveSpritePosition::default() + }, + ]); + let sprites = &store.sprite_positions["mode"]; + let first_pose_id = sprites[0].poses[0].pose_id.clone(); + let second_pose_id = sprites[1].poses[0].pose_id.clone(); + let mut candidate = sprites.clone(); + candidate[0].poses[0] = candidate[1].poses[0].clone(); + candidate[0].poses[0].pose_id.clear(); + + let assigned = prepare_v1_sprite_patch(&store, candidate); + let assigned_pose_id = &assigned[0].poses[0].pose_id; + + assert_ne!(assigned_pose_id, &first_pose_id); + assert_ne!(assigned_pose_id, &second_pose_id); + assert!(is_valid_element_id(assigned_pose_id)); + assert_eq!(assigned[1].poses[0].pose_id, second_pose_id); + } + + #[test] + fn v1_idless_new_sprite_parent_gets_fresh_pose_ids() { + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![sprite_pose(10, 1.0), sprite_pose(20, 2.0)], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_pose_ids = original + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + let mut candidate = original.clone(); + candidate.id.clear(); + for pose in &mut candidate.poses { + pose.pose_id.clear(); + } + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + + assert_ne!(assigned[0].id, original.id); + assert!(assigned[0] + .poses + .iter() + .all(|pose| !original_pose_ids.contains(&pose.pose_id))); + } + + #[test] + fn v1_idless_equal_sprite_poses_consume_current_ids_in_order() { + let pose = sprite_pose(10, 1.0); + let store = store_with_sprites(vec![ReactiveSpritePosition { + poses: vec![pose.clone(), pose], + ..ReactiveSpritePosition::default() + }]); + let original = &store.sprite_positions["mode"][0]; + let original_ids = original + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + let mut candidate = original.clone(); + for pose in &mut candidate.poses { + pose.pose_id.clear(); + } + + let assigned = prepare_v1_sprite_patch(&store, vec![candidate]); + let assigned_ids = assigned[0] + .poses + .iter() + .map(|pose| pose.pose_id.clone()) + .collect::>(); + + assert_eq!(assigned_ids, original_ids); + } + #[test] fn v1_preserves_explicit_current_ids_and_rekeys_stale_ids() { let store = store_with_all_collections(); From 9256eebf9a24f935b373895d872b68c066916e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 22:21:01 +0900 Subject: [PATCH 058/125] =?UTF-8?q?fix:=20=EC=9E=90=EC=84=B8=20=EC=9C=84?= =?UTF-8?q?=EB=B0=98=EC=9D=98=20=EC=95=88=EC=A0=95=20ID=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=EC=9D=84=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/state/editor.rs | 320 ++++++++++++++++++++++++++++------ 1 file changed, 267 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/state/editor.rs b/src-tauri/src/state/editor.rs index 94ea4a079..314200d91 100644 --- a/src-tauri/src/state/editor.rs +++ b/src-tauri/src/state/editor.rs @@ -132,7 +132,7 @@ enum ViolationPropertyPath { property: &'static str, }, SpritePoseProperty { - pose_index: usize, + pose_id: String, property: &'static str, }, } @@ -1086,6 +1086,18 @@ pub(crate) enum GrandfatherKeying { LegacyPresetModeIndex, } +#[derive(Default)] +struct NativeIdAliases { + elements: HashMap, + sprite_poses: HashMap, +} + +impl NativeIdAliases { + fn is_empty(&self) -> bool { + self.elements.is_empty() && self.sprite_poses.is_empty() + } +} + /// 기존 store에 있던 손실 없는 비정상 데이터는 유지하되 새 비정상 상태는 만들지 않음 pub(crate) fn validate_document_transition( current: &EditorDocumentV1, @@ -1128,7 +1140,7 @@ pub(crate) fn validate_document_transition_with_keying( validate_metric_limits(current, candidate, keying)?; let native_id_alias = match keying { - GrandfatherKeying::StableId => HashMap::new(), + GrandfatherKeying::StableId => NativeIdAliases::default(), #[cfg(test)] GrandfatherKeying::ModeIndex => native_id_alias_by_slot(current, candidate), GrandfatherKeying::LegacyPresetModeIndex => native_id_alias_by_slot(current, candidate), @@ -1146,49 +1158,76 @@ pub(crate) fn validate_document_transition_with_keying( Ok(()) } -// 후보 요소 id → 같은 (모드, 자리)의 현재 요소 id. 프리셋 트랜잭션은 커밋 -// 직전 id를 재발급하므로 신원으로는 관용 상대를 찾을 수 없다 +// 프리셋 후보 요소와 자세 id를 같은 모드와 자리의 현재 id로 연결 fn native_id_alias_by_slot( current: &EditorDocumentV1, candidate: &EditorDocumentV1, -) -> HashMap { - let mut alias = HashMap::new(); - let mut collect = |current_ids: Vec<(&String, Vec<&str>)>, - candidate_ids: Vec<(&String, Vec<&str>)>| { - let current_by_mode = current_ids.into_iter().collect::>(); - for (mode, ids) in candidate_ids { - let Some(current_mode_ids) = current_by_mode.get(mode) else { - continue; - }; - for (index, id) in ids.into_iter().enumerate() { - if let Some(current_id) = current_mode_ids.get(index) { - alias.insert(id.to_string(), current_id.to_string()); +) -> NativeIdAliases { + let mut aliases = NativeIdAliases::default(); + { + let mut collect = |current_ids: Vec<(&String, Vec<&str>)>, + candidate_ids: Vec<(&String, Vec<&str>)>| { + let current_by_mode = current_ids.into_iter().collect::>(); + for (mode, ids) in candidate_ids { + let Some(current_mode_ids) = current_by_mode.get(mode) else { + continue; + }; + for (index, id) in ids.into_iter().enumerate() { + if let Some(current_id) = current_mode_ids.get(index) { + aliases + .elements + .insert(id.to_string(), current_id.to_string()); + } } } - } - }; + }; - collect( - key_position_ids(¤t.key_positions), - key_position_ids(&candidate.key_positions), - ); - collect( - nested_position_ids(¤t.stat_positions), - nested_position_ids(&candidate.stat_positions), - ); - collect( - nested_position_ids(¤t.graph_positions), - nested_position_ids(&candidate.graph_positions), - ); - collect( - nested_position_ids(¤t.knob_positions), - nested_position_ids(&candidate.knob_positions), - ); - collect( - sprite_position_ids(¤t.sprite_positions), - sprite_position_ids(&candidate.sprite_positions), + collect( + key_position_ids(¤t.key_positions), + key_position_ids(&candidate.key_positions), + ); + collect( + nested_position_ids(¤t.stat_positions), + nested_position_ids(&candidate.stat_positions), + ); + collect( + nested_position_ids(¤t.graph_positions), + nested_position_ids(&candidate.graph_positions), + ); + collect( + nested_position_ids(¤t.knob_positions), + nested_position_ids(&candidate.knob_positions), + ); + collect( + sprite_position_ids(¤t.sprite_positions), + sprite_position_ids(&candidate.sprite_positions), + ); + } + collect_sprite_pose_id_aliases( + ¤t.sprite_positions, + &candidate.sprite_positions, + &mut aliases.sprite_poses, ); - alias + aliases +} + +fn collect_sprite_pose_id_aliases( + current: &HashMap>, + candidate: &HashMap>, + aliases: &mut HashMap, +) { + for (mode, candidate_sprites) in candidate { + let Some(current_sprites) = current.get(mode) else { + continue; + }; + for (candidate_sprite, current_sprite) in candidate_sprites.iter().zip(current_sprites) { + for (candidate_pose, current_pose) in + candidate_sprite.poses.iter().zip(¤t_sprite.poses) + { + aliases.insert(candidate_pose.pose_id.clone(), current_pose.pose_id.clone()); + } + } + } } fn sprite_position_ids( @@ -1265,7 +1304,7 @@ impl HasKeyPosition for KnobPosition { fn is_grandfathered( current_violation_keys: &BTreeSet, candidate: &ValidationViolation, - native_id_alias: &HashMap, + native_id_alias: &NativeIdAliases, ) -> bool { if current_violation_keys.contains(&candidate.key) { return true; @@ -1277,16 +1316,28 @@ fn is_grandfathered( let ViolationOwner::NativeElement { kind, id } = &candidate.key.owner else { return false; }; - let Some(current_id) = native_id_alias.get(id) else { + let Some(current_id) = native_id_alias.elements.get(id) else { return false; }; + let property_path = match &candidate.key.property_path { + ViolationPropertyPath::SpritePoseProperty { pose_id, property } => { + let Some(current_pose_id) = native_id_alias.sprite_poses.get(pose_id) else { + return false; + }; + ViolationPropertyPath::SpritePoseProperty { + pose_id: current_pose_id.clone(), + property, + } + } + property_path => property_path.clone(), + }; let aliased = ViolationKey { owner: ViolationOwner::NativeElement { kind: *kind, id: current_id.clone(), }, code: candidate.key.code, - property_path: candidate.key.property_path.clone(), + property_path, invalid_value: candidate.key.invalid_value.clone(), }; current_violation_keys.contains(&aliased) @@ -1686,7 +1737,7 @@ fn collect_sprite_violations( mode, sprite_index, "poseTransform", - Some(pose_index), + Some((pose_index, &pose.pose_id)), &pose.transform, violations, ); @@ -1699,7 +1750,7 @@ fn collect_sprite_violations( &sprite.id, "INVALID_SPRITE_CONTACT_POINT", ViolationPropertyPath::SpritePoseProperty { - pose_index, + pose_id: pose.pose_id.clone(), property, }, InvalidValueSignature::FloatBits(value.to_bits()), @@ -1717,7 +1768,7 @@ fn collect_sprite_violations( &sprite.id, "TOO_MANY_SPRITE_POSE_TRIGGERS", ViolationPropertyPath::SpritePoseProperty { - pose_index, + pose_id: pose.pose_id.clone(), property: "triggers", }, InvalidValueSignature::Count(pose.triggers.len()), @@ -1737,7 +1788,7 @@ fn collect_sprite_violations( &sprite.id, "INVALID_SPRITE_TRIGGER", ViolationPropertyPath::SpritePoseProperty { - pose_index, + pose_id: pose.pose_id.clone(), property: "triggers", }, InvalidValueSignature::Text(trigger.clone()), @@ -1754,10 +1805,10 @@ fn collect_sprite_violations( &sprite.id, "EMPTY_SPRITE_POSE_TRIGGERS", ViolationPropertyPath::SpritePoseProperty { - pose_index, + pose_id: pose.pose_id.clone(), property: "triggers", }, - InvalidValueSignature::Count(pose_index), + InvalidValueSignature::Empty, ), format!( "spritePositions {mode}[{sprite_index}].poses[{pose_index}].triggers must not be empty" @@ -1770,7 +1821,7 @@ fn collect_sprite_violations( &sprite.id, "DUPLICATE_SPRITE_POSE_TRIGGERS", ViolationPropertyPath::SpritePoseProperty { - pose_index, + pose_id: pose.pose_id.clone(), property: "triggers", }, InvalidValueSignature::Text(format!("{:?}", pose.triggers)), @@ -1790,7 +1841,7 @@ fn collect_sprite_transform_violations( mode: &str, sprite_index: usize, section: &'static str, - pose_index: Option, + pose_identity: Option<(usize, &str)>, transform: &SpriteTransform, violations: &mut BTreeSet, ) { @@ -1826,10 +1877,10 @@ fn collect_sprite_transform_violations( NativeElementKind::Sprite, &sprite.id, "INVALID_SPRITE_TRANSFORM", - pose_index.map_or( + pose_identity.map_or( ViolationPropertyPath::SpriteProperty { section, property }, - |pose_index| ViolationPropertyPath::SpritePoseProperty { - pose_index, + |(_, pose_id)| ViolationPropertyPath::SpritePoseProperty { + pose_id: pose_id.to_string(), property, }, ), @@ -5268,10 +5319,173 @@ mod tests { assert!(is_grandfathered( ¤t, &ValidationViolation::new(key, "different diagnostic message"), - &HashMap::new() + &NativeIdAliases::default() )); } + fn sprite_pose_with_triggers(triggers: Vec) -> SpritePose { + SpritePose { + pose_id: Uuid::new_v4().to_string(), + triggers, + ..SpritePose::default() + } + } + + fn store_with_grandfathered_sprite_poses(poses: Vec) -> AppStoreData { + let mut store = default_editor_store(); + store.sprite_positions.insert( + "4key".to_string(), + vec![ReactiveSpritePosition { + id: Uuid::new_v4().to_string(), + poses, + ..ReactiveSpritePosition::default() + }], + ); + store + } + + fn empty_trigger_violation_key( + document: &EditorDocumentV1, + store: &AppStoreData, + pose_id: &str, + ) -> ViolationKey { + collect_violations(document, &allowed_modes(store)) + .into_iter() + .find(|violation| { + violation.code() == "EMPTY_SPRITE_POSE_TRIGGERS" + && matches!( + &violation.key.property_path, + ViolationPropertyPath::SpritePoseProperty { + pose_id: violation_pose_id, + .. + } if violation_pose_id == pose_id + ) + }) + .expect("empty trigger violation exists") + .key + } + + #[test] + fn sprite_pose_violation_survives_deleting_an_earlier_pose() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let invalid = sprite_pose_with_triggers(Vec::new()); + let invalid_pose_id = invalid.pose_id.clone(); + let store = store_with_grandfathered_sprite_poses(vec![normal, invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .remove(0); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); + assert_eq!( + candidate.sprite_positions["4key"][0].poses[0].pose_id, + invalid_pose_id + ); + } + + #[test] + fn sprite_pose_violation_follows_pose_id_across_reorder() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let invalid = sprite_pose_with_triggers(Vec::new()); + let invalid_pose_id = invalid.pose_id.clone(); + let store = store_with_grandfathered_sprite_poses(vec![normal, invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .swap(0, 1); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + validate_document_transition(¤t, &candidate, &store, &candidate_store).unwrap(); + assert_eq!( + candidate.sprite_positions["4key"][0].poses[0].pose_id, + invalid_pose_id + ); + } + + #[test] + fn same_sprite_pose_violation_on_a_new_pose_id_is_rejected() { + let invalid = sprite_pose_with_triggers(Vec::new()); + let store = store_with_grandfathered_sprite_poses(vec![invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate = current.clone(); + candidate.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .push(sprite_pose_with_triggers(Vec::new())); + let mut candidate_store = store.clone(); + candidate.apply_to_store(&mut candidate_store); + + let error = validate_document_transition(¤t, &candidate, &store, &candidate_store) + .unwrap_err(); + + assert_eq!(validation_code(&error), Some("EMPTY_SPRITE_POSE_TRIGGERS")); + } + + #[test] + fn empty_sprite_pose_trigger_signature_is_index_independent() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let invalid = sprite_pose_with_triggers(Vec::new()); + let invalid_pose_id = invalid.pose_id.clone(); + let store = store_with_grandfathered_sprite_poses(vec![normal, invalid]); + let current = EditorDocumentV1::from_store(&store); + let current_key = empty_trigger_violation_key(¤t, &store, &invalid_pose_id); + let mut reordered = current.clone(); + reordered.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .swap(0, 1); + let reordered_key = empty_trigger_violation_key(&reordered, &store, &invalid_pose_id); + + assert_eq!(current_key, reordered_key); + assert_eq!(current_key.invalid_value, InvalidValueSignature::Empty); + } + + #[test] + fn preset_pose_alias_grandfathers_rekeyed_existing_violation() { + let invalid = sprite_pose_with_triggers(Vec::new()); + let store = store_with_grandfathered_sprite_poses(vec![invalid]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::LegacyPresetModeIndex, + ) + .unwrap(); + } + + #[test] + fn preset_pose_alias_rejects_new_violation() { + let normal = sprite_pose_with_triggers(vec![Uuid::new_v4().to_string()]); + let store = store_with_grandfathered_sprite_poses(vec![normal]); + let current = EditorDocumentV1::from_store(&store); + let mut candidate_store = store.clone(); + crate::state::native_element_id::rekey_store_element_ids(&mut candidate_store); + candidate_store.sprite_positions.get_mut("4key").unwrap()[0] + .poses + .push(sprite_pose_with_triggers(Vec::new())); + let candidate = EditorDocumentV1::from_store(&candidate_store); + + let error = validate_document_transition_with_keying( + ¤t, + &candidate, + &store, + &candidate_store, + GrandfatherKeying::LegacyPresetModeIndex, + ) + .unwrap_err(); + + assert_eq!(validation_code(&error), Some("EMPTY_SPRITE_POSE_TRIGGERS")); + } + #[test] fn stage_four_stable_id_grandfathers_violation_after_reorder() { let mut store = default_editor_store(); From b0b35bb9a1ecdc0f29d20b790c65e6c220c8cd97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 22:21:28 +0900 Subject: [PATCH 059/125] =?UTF-8?q?fix:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=AC=EC=83=9D=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=B7=B0=EB=A5=BC=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/services/preview_broker.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src-tauri/src/services/preview_broker.rs b/src-tauri/src/services/preview_broker.rs index 1ddf46559..3c678d4f0 100644 --- a/src-tauri/src/services/preview_broker.rs +++ b/src-tauri/src/services/preview_broker.rs @@ -98,6 +98,7 @@ const SPRITE_POSITION_PATCH_FIELDS: &[&str] = &[ "pivot", "idleTransform", "poses", + "pressDurationMs", "transitionMs", "transitionEasing", "imageFit", @@ -913,6 +914,7 @@ mod tests { "pivot": { "x": 0.5, "y": 0.75 }, "idleTransform": { "x": 0, "y": 0, "rotation": 0, "scale": 1 }, "poses": [{ "poseId": "pose-id", "triggers": [] }], + "pressDurationMs": 300, "transitionMs": 120, "transitionEasing": "ease-out", "imageFit": "contain", @@ -940,6 +942,28 @@ mod tests { assert_eq!(Value::Object(messages[0].patch.clone()), patch); } + #[test] + fn sprite_position_preview_allowlist_matches_supported_contract() { + assert_eq!( + SPRITE_POSITION_PATCH_FIELDS, + [ + "dx", + "dy", + "width", + "height", + "imageRect", + "pivot", + "idleTransform", + "poses", + "pressDurationMs", + "transitionMs", + "transitionEasing", + "imageFit", + "baseImage", + ] + ); + } + #[test] fn preview_patch_allowlist_rejects_unknown_and_cross_domain_fields() { let broker = PreviewBroker::default(); From 6c4e4b0551a0e947f92743a540cc189399771ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 22:22:06 +0900 Subject: [PATCH 060/125] =?UTF-8?q?fix:=20=EB=81=8A=EA=B8=B4=20=EC=9E=90?= =?UTF-8?q?=EC=84=B8=20=EB=93=9C=EB=9E=98=EA=B7=B8=EB=A5=BC=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=ED=95=98=EA=B2=8C=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/handles/SpritePoseGizmo.test.tsx | 31 ++++++++++++++++++- .../main/Grid/handles/SpritePoseGizmo.tsx | 5 +++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx index bceb54653..24f2bc839 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx @@ -56,7 +56,12 @@ describe('SpritePoseGizmo', () => { const pointer = ( type: string, target: EventTarget, - init: { clientX?: number; clientY?: number; pointerId?: number } = {}, + init: { + clientX?: number; + clientY?: number; + pointerId?: number; + buttons?: number; + } = {}, ) => act(() => { target.dispatchEvent( @@ -64,6 +69,8 @@ describe('SpritePoseGizmo', () => { bubbles: true, cancelable: true, button: 0, + // 실제 브라우저처럼 누른 상태가 기본이고 up만 0이다 + buttons: init.buttons ?? (type === 'pointerup' ? 0 : 1), clientX: init.clientX ?? 0, clientY: init.clientY ?? 0, pointerId: init.pointerId ?? 1, @@ -213,6 +220,28 @@ describe('SpritePoseGizmo', () => { releaseDragSession(); }); + it('창 밖에서 버튼이 떼진 stale move는 커밋 없이 드래그를 끝낸다', async () => { + const session = makeSession(); + render(session); + + pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); + pointer('pointermove', window, { clientX: 200, clientY: 50, buttons: 0 }); + + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(session.commit).not.toHaveBeenCalled(); + // 락을 돌려놔야 다음 드래그가 시작된다 + expect(tryAcquireDragSession()).toBe(true); + releaseDragSession(); + + // 끝난 드래그라 뒤늦은 move도 preview를 열지 않는다 + (session.preview as ReturnType).mockClear(); + pointer('pointermove', window, { clientX: 210, clientY: 60 }); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + expect(session.preview).not.toHaveBeenCalled(); + }); + it('소유권 무효화 뒤의 move는 preview를 되열지 않는다', async () => { const session = makeSession(); render(session); diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx index 5e2fd5498..4c2758472 100644 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx +++ b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx @@ -268,6 +268,11 @@ const SpritePoseGizmo = ({ zoom, panX, panY }: SpritePoseGizmoProps) => { const handleWindowMove = (event: PointerEvent) => { if (event.pointerId !== dragRef.current?.pointerId) return; + // 창 밖에서 버튼이 이미 떼졌으면 stale 드래그 - 커밋 없이 종료 + if (event.buttons === 0) { + cancelActiveDrag(); + return; + } moveSchedulerRef.current?.push(event); }; const handleWindowCancel = (event: PointerEvent) => { From 822cc617c13355b10128f350b90d7b2f7aa62336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 22:22:17 +0900 Subject: [PATCH 061/125] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=9B=8C=EB=B0=8D=EC=97=85=20=EC=8A=AC=EB=A1=AF=EC=9D=84=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/imageWarmup.settleTimeout.test.ts | 61 +++++++++++++++++++ src/renderer/utils/core/imageWarmup.ts | 9 +++ 2 files changed, 70 insertions(+) create mode 100644 src/renderer/utils/core/imageWarmup.settleTimeout.test.ts diff --git a/src/renderer/utils/core/imageWarmup.settleTimeout.test.ts b/src/renderer/utils/core/imageWarmup.settleTimeout.test.ts new file mode 100644 index 000000000..a4f3b8550 --- /dev/null +++ b/src/renderer/utils/core/imageWarmup.settleTimeout.test.ts @@ -0,0 +1,61 @@ +/** + * 워밍업 슬롯 만료 + * - load도 error도 오지 않는 source가 동시 상한을 채우면 큐 전체가 멈춘다 + * - 만료가 슬롯을 돌려줘 뒤따르는 source가 이어진다 + * 모듈 카운터가 파일 단위 상태라 누적 캐시 테스트와 분리한다 + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { warmupImageSource } from './imageWarmup'; + +type MockImage = { + onload: (() => void) | null; + onerror: (() => void) | null; + complete: boolean; + src: string; +}; + +describe('image warmup settle timeout', () => { + const originalImage = globalThis.Image; + + const installMockImage = (): MockImage[] => { + const images: MockImage[] = []; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + complete = false; + src = ''; + + constructor() { + images.push(this as unknown as MockImage); + } + } as unknown as typeof Image; + return images; + }; + + afterEach(() => { + globalThis.Image = originalImage; + vi.useRealTimers(); + }); + + it('settle되지 않는 source가 상한을 채워도 만료 뒤 큐가 이어진다', () => { + vi.useFakeTimers(); + const images = installMockImage(); + + for (let index = 0; index < 7; index += 1) { + warmupImageSource(`asset://stalled-${index}`); + } + expect(images).toHaveLength(6); + + // 만료 직전까지는 슬롯이 묶여 7번째가 시작되지 않는다 + vi.advanceTimersByTime(9_999); + expect(images).toHaveLength(6); + + vi.advanceTimersByTime(1); + expect(images).toHaveLength(7); + + // 만료 뒤 뒤늦게 도착한 load는 카운터를 두 번 깎지 않는다 + images[0].onload?.(); + warmupImageSource('asset://after-expiry'); + expect(images).toHaveLength(8); + }); +}); diff --git a/src/renderer/utils/core/imageWarmup.ts b/src/renderer/utils/core/imageWarmup.ts index 7c49319fc..460656047 100644 --- a/src/renderer/utils/core/imageWarmup.ts +++ b/src/renderer/utils/core/imageWarmup.ts @@ -3,6 +3,9 @@ const IMAGE_WARMUP_CACHE_LIMIT = 256; // 동시 디코드 상한 - 자세 이미지가 많은 문서가 마운트 순간 로드를 전부 // 시작하면 메모리·디코드 스파이크가 생긴다. 나머지는 FIFO로 뒤따른다 const MAX_CONCURRENT_WARMUPS = 6; +// 로드도 에러도 나지 않는 src가 슬롯을 붙잡으면 큐 전체가 멈춘다. 사전 디코드 +// 최적화라 포기해도 본 렌더의 img 로드는 그대로 진행된다 +const WARMUP_SETTLE_TIMEOUT_MS = 10_000; // 진행·대기 중 src - LRU 퇴출에서 제외해 같은 src의 중복 enqueue를 막는다 const inFlightSrcs = new Set(); @@ -42,10 +45,12 @@ export function warmupImageSource(src?: string | null): void { warmupQueue.push(() => { runningWarmups += 1; let settled = false; + let settleTimer: ReturnType | null = null; const done = () => { if (settled) return; settled = true; + if (settleTimer !== null) clearTimeout(settleTimer); runningWarmups -= 1; inFlightSrcs.delete(src); resolve(); @@ -53,6 +58,10 @@ export function warmupImageSource(src?: string | null): void { pumpWarmupQueue(); }; + // 멈춘 src가 슬롯을 영구히 물지 않게 한다. 만료 후 뒤늦게 load·error가 + // 와도 done은 한 번만 유효하다 + settleTimer = setTimeout(done, WARMUP_SETTLE_TIMEOUT_MS); + try { const img = new Image(); img.onload = done; From 57742eabf216a757ffcca3463d52a92a62d97af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 23:12:52 +0900 Subject: [PATCH 062/125] =?UTF-8?q?fix:=20=EB=84=98=EC=B9=98=EB=8A=94=20ea?= =?UTF-8?q?sing=20=EC=A0=9C=EC=96=B4=EC=A0=90=EC=9D=98=20=EB=8F=84?= =?UTF-8?q?=EB=8B=AC=20=EB=B2=94=EC=9C=84=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/utils/sprite/spriteReach.test.ts | 45 +++++++++++++++++++ src/renderer/utils/sprite/spriteReach.ts | 28 ++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/renderer/utils/sprite/spriteReach.test.ts b/src/renderer/utils/sprite/spriteReach.test.ts index 72ac9fc85..209120e11 100644 --- a/src/renderer/utils/sprite/spriteReach.test.ts +++ b/src/renderer/utils/sprite/spriteReach.test.ts @@ -169,6 +169,31 @@ describe('easingOutputRange', () => { expect(easingOutputRange('linear(-0.2, 0.5 50%, 1)').min).toBe(-0.2); }); + // 유한한 초대형 제어점은 3*y1이나 b*b가 넘쳐 판별식이 NaN이 되고, + // NaN >= 0이 false라 극점을 통째로 놓쳐 범위를 {0, 1}로 오판했다. + // 브라우저는 이 곡선을 거부하지 않으므로 창 여유 0으로 스프라이트가 잘린다 + it('중간 연산이 넘치면 제어점 볼록 껍질로 폴백한다', () => { + expect(easingOutputRange('cubic-bezier(0, 1e308, 1, 0)').max).toBe(1e308); + expect(easingOutputRange('cubic-bezier(0, -1e308, 1, 0)').min).toBe(-1e308); + // 3*y1 - 3*y2가 Infinity - Infinity로 상쇄되는 경우도 같다 + expect(easingOutputRange('cubic-bezier(0, 1e308, 1, 1e308)').max).toBe( + 1e308, + ); + // Number 변환 자체가 Infinity인 입력 + expect(easingOutputRange('cubic-bezier(0, 1e999, 1, 0)').max).toBe( + Infinity, + ); + }); + + it('넘치지 않는 큰 제어점은 해석적 극점을 그대로 쓴다', () => { + // 껍질(1e100)이 아니라 실제 극점이어야 한다 - 폴백이 상시 적용되면 안 된다 + expect(easingOutputRange('cubic-bezier(0, 1e100, 1, 0)').max).toBeCloseTo( + 4.444444444444445e99, + -85, + ); + expect(easingOutputRange('cubic-bezier(0.5, 2, 0.5, 1)').max).toBe(1.25); + }); + it('해석 불가 문자열은 스냅 전환이라 0~1로 본다', () => { expect(easingOutputRange('not-an-easing')).toEqual({ min: 0, max: 1 }); // x 제어점이 0~1 밖이면 CSS 선언 자체가 무효 @@ -201,6 +226,26 @@ describe('easingOvershootExtension', () => { it('지나침 폭 1 미만 easing은 강등 없이 유지된다', () => { expect(resolveSpriteRenderEasing(OVERSHOOT_EASING)).toBe(OVERSHOOT_EASING); expect(resolveSpriteRenderEasing('linear')).toBe('linear'); + // 넘치지 않는 큰 제어점은 지나침이 커서 강등되지만, 판정은 해석적 값 기준이다 + expect(resolveSpriteRenderEasing('cubic-bezier(0.5, 2, 0.5, 1)')).toBe( + 'cubic-bezier(0.5, 2, 0.5, 1)', + ); + }); + + // 범위를 {0, 1}로 오판하면 강등도 안 걸리고 창 여유도 0이라 + // 실제 곡선이 아무리 튀어도 방어가 전부 통과해 버린다 + it('연산이 넘치는 제어점은 강등되고 창 여유가 0이다', () => { + for (const easing of [ + 'cubic-bezier(0, 1e308, 1, 0)', + 'cubic-bezier(0, -1e308, 1, 0)', + 'cubic-bezier(0, 1e308, 1, 1e308)', + 'cubic-bezier(0, 1e999, 1, 0)', + ]) { + expect(resolveSpriteRenderEasing(easing)).toBe( + SPRITE_SAFE_FALLBACK_EASING, + ); + expect(easingOvershootExtension(easing)).toBe(0); + } }); // WAAPI는 무효 easing에 TypeError를 던져 단발 재생을 통째로 끊는다. diff --git a/src/renderer/utils/sprite/spriteReach.ts b/src/renderer/utils/sprite/spriteReach.ts index 7a7743a43..00a2ee58d 100644 --- a/src/renderer/utils/sprite/spriteReach.ts +++ b/src/renderer/utils/sprite/spriteReach.ts @@ -58,15 +58,26 @@ const cubicBezierYRange = ( const u = 1 - t; return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t; }; + // y(t)는 제어점 y {0, y1, y2, 1}의 볼록 결합이라 항상 이 껍질 안에 있다. + // min·max뿐이라 overflow가 없어, 해석적 계산을 믿을 수 없을 때 쓰는 보수적 상한 + const hull = { min: Math.min(0, y1, y2), max: Math.max(1, y1, y2) }; + // 비유한 y는 끝점 evaluate도 0 * Infinity = NaN이라 계산 전에 껍질로 나간다 + if (!Number.isFinite(y1) || !Number.isFinite(y2)) return hull; + // dy/dt / 3 = a·t² + b·t + c const a = 3 * y1 - 3 * y2 + 1; const b = 2 * y2 - 4 * y1; const c = y1; + // y가 유한해도 중간 연산이 넘칠 수 있다. 넘친 값으로 판별식을 구하면 NaN이 되고 + // NaN >= 0이 false라 극점을 통째로 놓쳐 범위를 {0, 1}로 오판한다 + if (!Number.isFinite(a) || !Number.isFinite(b)) return hull; + const candidates = [0, 1]; if (Math.abs(a) < 1e-12) { if (Math.abs(b) > 1e-12) candidates.push(-c / b); } else { const discriminant = b * b - 4 * a * c; + if (!Number.isFinite(discriminant)) return hull; if (discriminant >= 0) { const sqrtDiscriminant = Math.sqrt(discriminant); candidates.push( @@ -78,8 +89,9 @@ const cubicBezierYRange = ( let min = Infinity; let max = -Infinity; for (const t of candidates) { - if (t < 0 || t > 1) continue; + if (!Number.isFinite(t) || t < 0 || t > 1) continue; const value = evaluate(t); + if (!Number.isFinite(value)) return hull; min = Math.min(min, value); max = Math.max(max, value); } @@ -163,10 +175,18 @@ const isRenderableEasing = (easing: string): boolean => { } const bezier = CUBIC_BEZIER_RE.exec(trimmed); - // x 제어점이 0~1 밖이면 무효 곡선 + // x 제어점이 0~1 밖이면 무효 곡선. 비유한 y는 엔진이 유한값으로 잘라 + // 도달 계산과 실제 전환이 갈리므로 linear() 정지점과 같은 기준으로 강등한다 if (bezier) { - const [x1, , x2] = bezier.slice(1, 5).map(Number); - return x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1; + const [x1, y1, x2, y2] = bezier.slice(1, 5).map(Number); + return ( + x1 >= 0 && + x1 <= 1 && + x2 >= 0 && + x2 <= 1 && + Number.isFinite(y1) && + Number.isFinite(y2) + ); } const linearFn = LINEAR_FN_RE.exec(trimmed); From 391f4572ad01bdc4a0f5557f46c2935c9500385c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Tue, 1 Sep 2026 23:13:03 +0900 Subject: [PATCH 063/125] =?UTF-8?q?fix:=20=EC=9D=B4=EC=A0=84=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=ED=82=A4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EC=83=88=20=ED=83=AD=EC=97=90=20=EC=A0=81=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/App.keyDelayTimers.test.tsx | 29 +++++++++++++++++-- src/renderer/windows/overlay/App.tsx | 7 ++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx index 58ba427d7..76f7d8691 100644 --- a/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx +++ b/src/renderer/windows/overlay/App.keyDelayTimers.test.tsx @@ -138,12 +138,12 @@ describe('키 표시 지연 타이머', () => { }); }; - const emitKeyState = (state: 'DOWN' | 'UP') => { + const emitKeyState = (state: 'DOWN' | 'UP', mode = '4key') => { act(() => { mocks.keyEventListener?.({ key: 'KeyK', state, - mode: '4key', + mode, eventAgeMs: 0, }); }); @@ -237,6 +237,31 @@ describe('키 표시 지연 타이머', () => { unsubscribe(); }); + it('탭 전환 직후 도착한 이전 모드 이벤트는 새 탭 신호를 켜지 않는다', async () => { + // 백엔드 확인 전에 화면 모드만 먼저 바뀐 구간을 재현 + await act(async () => { + useKeyStore.setState({ + selectedKeyType: '8key', + keyMappings: { '4key': ['KeyK'], '8key': ['KeyK'] }, + positions: { + '4key': [createDefaultKeyPosition(0, 0)], + '8key': [createDefaultKeyPosition(0, 0)], + }, + }); + }); + await flushAsync(); + + emitKeyState('DOWN', '4key'); + expect(vi.getTimerCount()).toBe(0); + expect(getKeySignal('KeyK').value).toBe(false); + + // 현재 탭 이벤트는 그대로 반영된다 + emitKeyState('DOWN', '8key'); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(30000); + expect(getKeySignal('KeyK').value).toBe(true); + }); + it('keys reset 시 모든 키 타이머를 취소한다', () => { const transitions: boolean[] = []; const unsubscribe = getKeySignal('KeyK').subscribe((value) => { diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index d4be54049..879a11554 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -520,8 +520,13 @@ export default function App() { if (hydrationCancelled) return undefined; const unsubscribeKeyEvents = keyEventBus.subscribe( ({ key, state, mode, eventAgeMs, holdDurationMs }) => { - seenSinceResetRef.current.add(key); + // 대조 원장은 모드로 네임스페이스돼 있어 낡은 모드도 그대로 기록한다 reconcileSeenRef.current?.add(`${mode}::${key}`); + // 탭 전환은 백엔드 확인 전에 화면 모드를 먼저 바꾸므로, 그 사이 도착한 + // 이전 모드 이벤트가 새 탭 신호를 켜면 onPress 스프라이트까지 발동한다. + // 대조 경로(reconcileWithBootstrap)가 쓰는 기준과 같게 건다 + if (mode !== keyEventContextRef.current.selectedKeyType) return; + seenSinceResetRef.current.add(key); const isDown = state === 'DOWN'; // 키 UI 업데이트 (딜레이 적용) updateKeySignalWithDelay(key, isDown); From 7e54c5ade62eb7d50a6708009f436ef8191872b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 2 Sep 2026 01:01:17 +0900 Subject: [PATCH 064/125] =?UTF-8?q?fix:=20=EC=97=94=EC=A7=84=EC=9D=B4=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80=ED=95=98=EB=8A=94=20easing=EC=9D=98=20whileH?= =?UTF-8?q?eld=20=EC=A0=84=ED=99=98=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../overlay/counters/OverlaySpriteItem.tsx | 24 ++++++++++--- .../counters/overlaySpriteItem.test.tsx | 35 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index cf9db46cf..5e6ef0615 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -19,7 +19,10 @@ import { computeSpriteImageStyle, spriteTransformToCss, } from '@utils/sprite/spriteImageStyles'; -import { resolveSpriteRenderEasing } from '@utils/sprite/spriteReach'; +import { + resolveSpriteRenderEasing, + SPRITE_SAFE_FALLBACK_EASING, +} from '@utils/sprite/spriteReach'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; interface OverlaySpriteItemProps { @@ -98,10 +101,21 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ // easing 해석은 정규식·베지어 극값 계산이라 렌더마다 돌리지 않는다. // 발산 easing은 도달 계산과 같은 기준으로 강등 (창 클리핑 방지) - const renderEasing = useMemo( - () => resolveSpriteRenderEasing(position.transitionEasing), - [position.transitionEasing], - ); + const renderEasing = useMemo(() => { + const resolved = resolveSpriteRenderEasing(position.transitionEasing); + // onPress의 보간자는 WAAPI뿐이라 전환 선언을 내지 않는다 - 엔진 질의도 생략 + if (isOneShot) return resolved; + // CSS.supports가 없으면 판정할 방법이 없으니 통과시킨다 (테스트 DOM 포함) + if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') { + return resolved; + } + // whileHeld의 보간자는 CSS transition이라 WAAPI try/catch가 닿지 않는다. + // 무효한 timing-function은 선언 전체가 버려져 0ms 스냅이 되므로, 문법 게이트를 + // 통과한 값도 선언 전에 엔진에 직접 물어본다 + return CSS.supports('transition-timing-function', resolved) + ? resolved + : SPRITE_SAFE_FALLBACK_EASING; + }, [position.transitionEasing, isOneShot]); // onPress는 전환 채널 미사용 - WAAPI가 유일한 보간자다 const transitionCss = isOneShot ? undefined diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index 3e7511695..c28fd645d 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -174,6 +174,41 @@ describe('OverlaySpriteItem', () => { ).toBe('transform 90ms ease'); }); + // whileHeld의 보간자는 CSS transition이라 onPress의 WAAPI try/catch가 닿지 않는다. + // 엔진이 모르는 timing-function은 선언 전체가 버려져 0ms 스냅이 되므로, + // 문법 게이트를 통과한 값도 선언 전에 엔진 지원을 확인해야 한다 + it('엔진이 거부하는 easing은 whileHeld 전환에서도 폴백 곡선으로 강등된다', () => { + const unsupported = 'linear(0, 0.25 75%, 1)'; + const supports = vi + .spyOn(CSS, 'supports') + .mockImplementation((...args: unknown[]) => + args[0] === 'transition-timing-function' && args[1] === unsupported + ? false + : true, + ); + + render(makeSprite({ transitionEasing: unsupported })); + + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transition-default'), + ).toBe('transform 90ms ease'); + supports.mockRestore(); + }); + + // 강등 게이트가 지원되는 곡선까지 삼키면 whileHeld가 통째로 기본 곡선이 된다. + // 폴백만 검사하면 그 회귀를 못 잡는다 + it('엔진이 지원하는 easing은 원문 그대로 전환에 실린다', () => { + const supported = 'linear(0, 0.5 50%, 1)'; + const supports = vi.spyOn(CSS, 'supports').mockReturnValue(true); + + render(makeSprite({ transitionEasing: supported })); + + expect( + imgEl()?.style.getPropertyValue('--dmn-sprite-transition-default'), + ).toBe(`transform 90ms ${supported}`); + supports.mockRestore(); + }); + it('useInlineStyles=true는 외관 채널을 인라인 선언으로 승격한다', () => { render(makeSprite({ useInlineStyles: true, imageFit: 'cover' })); From 40f7d00f1b70381d3d1810105c693e363f399b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Wed, 2 Sep 2026 01:01:24 +0900 Subject: [PATCH 065/125] =?UTF-8?q?fix:=20=EA=B3=B5=EB=B0=B1=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B0=B8=EC=A1=B0=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=86=EC=9D=8C=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.test.tsx | 22 +++++++++++ .../single/SpriteImagePreviewCard.tsx | 2 + .../Grid/layers/SpriteItem.render.test.tsx | 24 ++++++++++++ .../main/Grid/layers/SpriteItem.tsx | 3 +- .../overlay/counters/OverlaySpriteItem.tsx | 9 +++-- .../counters/overlaySpriteItem.test.tsx | 17 ++++++++ src/renderer/utils/core/imageSource.ts | 15 +++++++ .../utils/sprite/poseResolver.test.ts | 39 ++++++++++++++++++- src/renderer/utils/sprite/poseResolver.ts | 19 +++++++-- src/renderer/utils/sprite/spriteReach.test.ts | 15 +++++++ src/renderer/utils/sprite/spriteReach.ts | 6 ++- 11 files changed, 161 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index 94600a9f4..800e27dbc 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -1064,6 +1064,28 @@ describe('SingleSpritePanel 자세 편집', () => { }); }); + // 스키마가 빈 문자열을 막지 않아 플러그인·임포트로 들어온다. 그릴 수는 없지만 + // 저장소에는 남아 있으므로, 칩이 그 값을 지우는 유일한 길이다 + it('공백뿐인 이미지 경로는 그리지 않되 초기화로 정리할 수 있다', () => { + const position = spritePosition({ baseImage: ' ' }); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.configure').click()); + const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; + + expect(popup.querySelector('img')).toBeNull(); + + const reset = popup.querySelector( + 'button[aria-label="imagePicker.reset"]', + )!; + expect(reset).toBeTruthy(); + act(() => reset.click()); + expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ + baseImage: null, + }); + }); + it('이미지 설정 팝업의 위치·기준점 입력은 imageRect·pivot 패치로 커밋한다', () => { const position = spritePosition(); seed(position); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx index 57120aa66..33dc6f8cd 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx @@ -50,6 +50,8 @@ const SpriteImagePreviewCard = ({ onClick={onPick} className="absolute inset-0 bg-black opacity-0 hover:opacity-40 focus-visible:opacity-40 transition-opacity cursor-pointer" /> + {/* 초기화는 그림이 아니라 저장값 기준 - 공백 경로처럼 그릴 수 없는 값도 + 지울 수 있어야 한다. 칩이 그 값의 유일한 표시다 (이미지 피커와 같은 기준) */} {source ? ( + onCancel={() => editGestureController.cancel()} + prefix="Y" + ariaLabel={`${t('propertiesPanel.position') || '위치'} Y`} + width={AXIS_FIELD_WIDTH} + min={BOUNDS_EDIT_LIMITS.coordMin} + max={BOUNDS_EDIT_LIMITS.coordMax} + allowDecimal + decimalScale={1} + /> + + + + commitBounds(aspectBounds('width', value)) + } + onPreview={(value) => + previewBounds(aspectBounds('width', value)) + } + onCancel={() => editGestureController.cancel()} + prefix="W" + ariaLabel={`${t('propertiesPanel.size') || '크기'} W`} + width={AXIS_FIELD_WIDTH} + min={BOUNDS_EDIT_LIMITS.dimensionMin} + max={BOUNDS_EDIT_LIMITS.dimensionMax} + allowDecimal + decimalScale={1} + /> + + commitBounds(aspectBounds('height', value)) + } + onPreview={(value) => + previewBounds(aspectBounds('height', value)) + } + onCancel={() => editGestureController.cancel()} + prefix="H" + ariaLabel={`${t('propertiesPanel.size') || '크기'} H`} + width={AXIS_FIELD_WIDTH} + min={BOUNDS_EDIT_LIMITS.dimensionMin} + max={BOUNDS_EDIT_LIMITS.dimensionMax} + allowDecimal + decimalScale={1} + /> - {/* 기준점 - 회전·배율의 축, 이미지 상자 기준 비율(%). - 만지는 동안 캔버스에 십자 마커가 뜬다 */} - -
setPivotEngaged(true)} - onBlurCapture={(event) => { - if ( - !event.currentTarget.contains( - event.relatedTarget as Node | null, - ) - ) { - setPivotEngaged(false); - } - }} - onPointerDownCapture={() => setPivotEngaged(true)} - > - - commitFields({ - pivot: { - ...position.pivot, - x: percentToAnchor(value), - }, - }) - } - onPreview={(value) => - previewFields({ - pivot: { - ...position.pivot, - x: percentToAnchor(value), - }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="X" - suffix="%" - ariaLabel={`${ - t('propertiesPanel.spritePivot') || '기준점' - } X`} - width={AXIS_FIELD_WIDTH} - min={anchor.min * 100} - max={anchor.max * 100} - allowDecimal - decimalScale={1} - /> - - commitFields({ - pivot: { - ...position.pivot, - y: percentToAnchor(value), - }, - }) - } - onPreview={(value) => - previewFields({ - pivot: { - ...position.pivot, - y: percentToAnchor(value), - }, - }) - } - onCancel={() => editGestureController.cancel()} - prefix="Y" - suffix="%" - ariaLabel={`${ - t('propertiesPanel.spritePivot') || '기준점' - } Y`} - width={AXIS_FIELD_WIDTH} - min={anchor.min * 100} - max={anchor.max * 100} - allowDecimal - decimalScale={1} - /> -
+ {/* 기준점 - 회전·배율 축이자 자세 이미지의 고정점. 위치·크기와 같은 한 줄 행. + 9점 프리셋은 캔버스 표식 드래그의 자석 스냅이 맡는다 */} + + + commitPivot({ x: percentToAnchor(value) }) + } + onPreview={(value) => + previewPivot({ + ...canonicalPosition.pivot, + x: percentToAnchor(value), + }) + } + onCancel={() => editGestureController.cancel()} + prefix="X" + suffix="%" + ariaLabel={`${pivotLabel} X`} + width={AXIS_FIELD_WIDTH} + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> + + commitPivot({ y: percentToAnchor(value) }) + } + onPreview={(value) => + previewPivot({ + ...canonicalPosition.pivot, + y: percentToAnchor(value), + }) + } + onCancel={() => editGestureController.cancel()} + prefix="Y" + suffix="%" + ariaLabel={`${pivotLabel} Y`} + width={AXIS_FIELD_WIDTH} + min={anchor.min * 100} + max={anchor.max * 100} + allowDecimal + decimalScale={1} + /> @@ -1088,25 +1262,19 @@ export const SingleSpritePanel: React.FC = ({
{displayPoses.map((pose, poseIndex) => { const isDuplicate = duplicatePose?.poseId === pose.poseId; const isEmpty = pose.triggers.length === 0; - const isEditing = - activeEditorTarget?.kind === 'pose' && - activeEditorTarget.poseId === pose.poseId; + const isEditing = activeEditorTarget?.poseId === pose.poseId; const displayName = pose.name || resolvedNames[poseIndex]; const isRenamingPose = renamingPoseId === pose.poseId; const openPose = (anchor: HTMLElement) => openEditorPopup( - { - kind: 'pose', - positionId: position.id, - poseId: pose.poseId, - }, + { positionId: position.id, poseId: pose.poseId }, anchor, ); return ( @@ -1330,35 +1498,20 @@ export const SingleSpritePanel: React.FC = ({
- {/* 편집 팝업 - 이미지 설정·상태 편집이 한 자리를 배타로 쓴다 */} - - {imageEditing ? ( - + {editingPose ? ( + editGestureController.cancel()} - onImagePick={() => void handleBaseImageSelect()} - onImageReset={handleBaseImageReset} - onPlacementChange={(next) => void handlePlacementChange(next)} - onClose={() => setEditorTarget(null)} - t={t} - /> - ) : editingPose ? ( - = ({ triggers: editingPose.triggers, isDuplicate: duplicatePose?.poseId === editingPose.poseId, imageOverride: editingPose.imageOverride, - // 축 배치는 캔버스가 비트맵을 그대로 그리므로 썸네일도 맞춤으로 - imageFit: - position.imagePlacement === 'pivot' - ? 'contain' - : position.imageFit, onToggleTrigger: (keyId) => togglePoseTrigger(editingPoseIndex, keyId), onImagePick: () => void handlePoseImageSelect(editingPose.poseId), - // 이미지를 지우면 그 이미지에 매인 크기·축도 함께 지운다 + // 이미지를 지우면 그 이미지에 매인 크기도 함께 지운다 onImageReset: () => replacePose(editingPoseIndex, { imageOverride: null, imageOverrideMetrics: null, - imagePivot: null, }), }} // 콜백 부재는 접두 스크럽 자체를 꺼 버린다 - 항상 넘기고 안에서 분기 onTransformCommit={handleTransformCommit} onTransformPreview={handleTransformPreview} onTransformCancel={handleTransformCancel} - pinControls={{ - contactPoint: editingPose.contactPoint, - pinLock: pinLockEnabled, - stretch: stretchEnabled, - onContactPointCommit: (point) => - replacePose(editingPoseIndex, { contactPoint: point }), - onContactPointPreview: (point) => - previewPosePatch({ contactPoint: point }), - onPinLockToggle: () => setPinLockEnabled((value) => !value), - onStretchToggle: () => setStretchEnabled((value) => !value), - }} - pivotControls={{ - placement: position.imagePlacement, - hasOverride: - toRenderableImageRef(editingPose.imageOverride) !== null, - imagePivot: editingPose.imagePivot, - spritePivot: position.pivot, - onImagePivotCommit: (point) => - replacePose(editingPoseIndex, { imagePivot: point }), - onImagePivotPreview: (point) => - previewPosePatch({ imagePivot: point }), - }} + onPivotCommit={handlePosePivotCommit} + onPivotPreview={handlePosePivotPreview} + onPivotLinkChange={handlePosePivotLinkChange} onClose={() => setEditorTarget(null)} t={t} /> diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx index 3e8dbf065..abdb73094 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.tsx @@ -4,25 +4,19 @@ import { useFailedImageSrcs, } from '@hooks/overlay/useFailedImageSrcs'; import SpriteImagePlaceholder from '@components/main/common/SpriteImagePlaceholder'; -import { - DEFAULT_SPRITE_IMAGE_FIT, - type SpriteImageFit, -} from '@src/types/key/sprites'; interface SpriteImagePreviewCardProps { // 저장된 경로 그대로 - 표시용 URL 변환은 카드가 맡는다 source: string | null; - imageFit: SpriteImageFit | null; onPick: () => void; onReset: () => void; t: (key: string) => string; } // 스프라이트 이미지 미리보기 - 전면 선택 버튼과 형제 초기화 버튼 (중첩 버튼 금지). -// 기본 이미지와 상태 이미지가 같은 문법을 쓴다 +// 기본 이미지와 상태 이미지가 같은 문법을 쓴다. 썸네일은 비트맵을 그대로 담는 맞춤 const SpriteImagePreviewCard = ({ source, - imageFit, onPick, onReset, t, @@ -51,8 +45,7 @@ const SpriteImagePreviewCard = ({ src={imageSrc} alt="" draggable={false} - className="absolute inset-0 block w-full h-full pointer-events-none select-none" - style={{ objectFit: imageFit ?? DEFAULT_SPRITE_IMAGE_FIT }} + className="absolute inset-0 block w-full h-full pointer-events-none select-none object-contain" onError={(event) => { if (!isErrorForCurrentSrc(event.currentTarget, imageSrc)) return; markFailed(imageSrc); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx index 8b92d1978..cb239fc02 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -1,11 +1,7 @@ import React from 'react'; import PickerSurface from '@components/main/Grid/PropertiesPanel/PickerSurface'; -import { - PropertyRow, - PropertySection, -} from '@components/main/Grid/PropertiesPanel/PropertyInputs'; -import Checkbox from '@components/main/common/Checkbox'; import Dropdown from '@components/main/common/Dropdown'; +import Checkbox from '@components/main/common/Checkbox'; import { NumberInput } from '@components/main/common/NumberInput'; import { AngleGlyph, @@ -14,13 +10,9 @@ import { import { SPRITE_CONSTRAINTS, type SpriteAnchor, - type SpriteImageFit, - type SpriteImagePlacement, type SpriteTransform, } from '@src/types/key/sprites'; -import { SECTION_LABEL_CLASS, SECTION_WRAPPER_CLASS } from '@utils/cardRecipes'; import { clamp } from '@utils/core/clamp'; -import { anchorToPercent, percentToAnchor } from '@utils/sprite/spriteGeometry'; import SpriteImagePreviewCard from './SpriteImagePreviewCard'; // 담당 키·이미지 오버라이드 컨트롤 묶음 (삭제·이름 변경은 행 메뉴가 맡는다) @@ -29,59 +21,36 @@ interface SpritePoseControls { triggers: readonly string[]; isDuplicate: boolean; imageOverride: string | null; - // 미리보기 표시 방식 - 캔버스 렌더가 스프라이트 imageFit을 따르므로 동일 적용 - imageFit: SpriteImageFit | null; onToggleTrigger: (keyId: string) => void; onImagePick: () => void; onImageReset: () => void; } -// 손끝 핀 컨트롤 묶음 - 값은 캔버스 노브(Alt 드래그)와 같은 contactPoint -interface SpritePosePinControls { - contactPoint: SpriteAnchor; - /** 회전·배율 스크럽이 손끝을 제자리에 두도록 x·y를 역산 */ - pinLock: boolean; - /** 캔버스 노브 드래그가 scale까지 역산 */ - stretch: boolean; - onContactPointCommit: (point: SpriteAnchor) => void; - onContactPointPreview: (point: SpriteAnchor) => void; - onPinLockToggle: () => void; - onStretchToggle: () => void; -} - -// 자세 이미지 축 컨트롤 묶음 - 축 배치에서만 뜻이 있다. null이면 스프라이트 기준점 상속 -interface SpritePosePivotControls { - placement: SpriteImagePlacement; - /** 자세 이미지가 없으면 축은 기본 이미지 것이라 편집 대상이 아니다 */ - hasOverride: boolean; - imagePivot: SpriteAnchor | null; - spritePivot: SpriteAnchor; - onImagePivotCommit: (point: SpriteAnchor | null) => void; - onImagePivotPreview: (point: SpriteAnchor) => void; -} - interface SpritePoseEditorPopupProps { open: boolean; ariaLabel: string; // 편집 세션 신원 - 내부 subtree 재마운트와 앵커 재측정의 기준 poseId: string; transform: SpriteTransform; + pivot: SpriteAnchor; + followsBasePivot: boolean; referenceRef: React.RefObject; panelElement: HTMLElement | null; poseControls: SpritePoseControls; - pinControls: SpritePosePinControls; - pivotControls: SpritePosePivotControls; // 행 전환 시 바깥닫힘을 거치지 않는 영역 (상태 목록 well) interactiveRefs?: React.RefObject[]; - onTransformCommit: (next: SpriteTransform) => void; + onTransformCommit: (patch: Partial) => void; onTransformPreview: (next: SpriteTransform) => void; onTransformCancel: () => void; + onPivotCommit: (patch: Partial) => void; + onPivotPreview: (next: SpriteAnchor) => void; + onPivotLinkChange: () => void; onClose: () => void; t: (key: string) => string; } -// 상태 하나의 편집 팝업 - 담당 키·변환·상태 이미지를 모아 패널 행을 요약으로 남긴다. -// 카드 규격·변환 그리드는 이미지 피커와 동일. +// 상태 하나의 편집 팝업 - 상태 이미지·변환 수치·담당 키. 위치·회전·배율은 캔버스 +// 핸들(본체 드래그·회전 노브·모서리)과 같은 값이라 여기서는 수치 입력만 맡는다. // 셸(PickerSurface)은 행 전환 동안 유지하고 편집 subtree만 poseId로 재마운트해 // 입력 draft·포커스는 대상별로 끊고 전환 자체는 이어지게 한다 const SpritePoseEditorPopup: React.FC = ({ @@ -89,23 +58,31 @@ const SpritePoseEditorPopup: React.FC = ({ ariaLabel, poseId, transform, + pivot, + followsBasePivot, referenceRef, panelElement, poseControls, - pinControls, - pivotControls, interactiveRefs, onTransformCommit, onTransformPreview, onTransformCancel, + onPivotCommit, + onPivotPreview, + onPivotLinkChange, onClose, t, }) => { - const { offset, rotation, scale } = SPRITE_CONSTRAINTS; + const { offset, rotation, scale, anchor } = SPRITE_CONSTRAINTS; + // 커밋은 바뀐 축만 올린다 - 전체를 펼쳐 보내면 저장 큐에 대기 중인 직전 편집을 덮는다 const commitField = (patch: Partial) => - onTransformCommit({ ...transform, ...patch }); + onTransformCommit(patch); const previewField = (patch: Partial) => onTransformPreview({ ...transform, ...patch }); + const commitPivotField = (patch: Partial) => + onPivotCommit(patch); + const previewPivotField = (patch: Partial) => + onPivotPreview({ ...pivot, ...patch }); const keyOptionIds = new Set( poseControls.keyOptions.map((option) => option.id), @@ -121,8 +98,8 @@ const SpritePoseEditorPopup: React.FC = ({ referenceRef={referenceRef} panelElement={panelElement} fallbackWidth={172} - // 미리보기 76 + 변환 그리드 50 + 손끝 섹션 127 + 담당 키 23 + 간격·패딩 40 - fallbackHeight={316} + // 미리보기 76 + 변환 그리드 68 + 담당 키 32 + 간격·패딩 32 + fallbackHeight={followsBasePivot ? 240 : 276} cardClassName="flex flex-col p-[8px] gap-[8px] w-[172px] rounded-popup" offsetY={-93} interactiveRefs={interactiveRefs} @@ -134,7 +111,6 @@ const SpritePoseEditorPopup: React.FC = ({ {/* 상태 이미지 - 비우면 캔버스가 기본 이미지를 그대로 쓴다 */} = ({
- {/* 손끝(핀) - 자세 이미지 기준 %. 캔버스 노브·Alt 드래그와 같은 값. - X·Y 쌍은 라벨을 옆에 둘 폭이 없어 섹션 라벨 + 설정 카드 레시피로 묶고, - 토글 행은 이미지 피커의 설정 카드와 같은 행 문법을 따른다 */} -
-

- {t('propertiesPanel.spriteContactPoint') || '손끝'} -

- -
+
+
+ + {t('propertiesPanel.spriteFollowBasePivot') || + '기본 기준점 따라가기'} + + +
+ {!followsBasePivot ? ( +
- pinControls.onContactPointCommit({ - ...pinControls.contactPoint, - x: percentToAnchor(value), + commitPivotField({ + x: clamp(value / 100, anchor.min, anchor.max), }) } onPreview={(value) => - pinControls.onContactPointPreview({ - ...pinControls.contactPoint, - x: percentToAnchor(value), + previewPivotField({ + x: clamp(value / 100, anchor.min, anchor.max), }) } onCancel={onTransformCancel} prefix="X" - suffix="%" ariaLabel={`${ - t('propertiesPanel.spriteContactPoint') || '손끝' + t('propertiesPanel.spriteStatePivot') || '상태 기준점' } X`} + suffix="%" width="100%" - min={0} - max={100} + min={anchor.min * 100} + max={anchor.max * 100} allowDecimal decimalScale={1} /> - pinControls.onContactPointCommit({ - ...pinControls.contactPoint, - y: percentToAnchor(value), + commitPivotField({ + y: clamp(value / 100, anchor.min, anchor.max), }) } onPreview={(value) => - pinControls.onContactPointPreview({ - ...pinControls.contactPoint, - y: percentToAnchor(value), + previewPivotField({ + y: clamp(value / 100, anchor.min, anchor.max), }) } onCancel={onTransformCancel} prefix="Y" - suffix="%" ariaLabel={`${ - t('propertiesPanel.spriteContactPoint') || '손끝' + t('propertiesPanel.spriteStatePivot') || '상태 기준점' } Y`} + suffix="%" width="100%" - min={0} - max={100} + min={anchor.min * 100} + max={anchor.max * 100} allowDecimal decimalScale={1} />
- - - - - - - + ) : null}
- {/* 축 - 축 배치에서 자세 이미지의 회전·배율 축. 기본은 스프라이트 기준점 상속이고, - 그림의 손목 위치가 다른 이미지만 따로 찍는다 */} - {pivotControls.placement === 'pivot' && pivotControls.hasOverride ? ( -
-

- {t('propertiesPanel.spriteImagePivot') || '축'} -

- - - - pivotControls.onImagePivotCommit( - pivotControls.imagePivot === null - ? { ...pivotControls.spritePivot } - : null, - ) - } - /> - - {pivotControls.imagePivot !== null ? ( -
- - pivotControls.onImagePivotCommit({ - ...(pivotControls.imagePivot as SpriteAnchor), - x: percentToAnchor(value), - }) - } - onPreview={(value) => - pivotControls.onImagePivotPreview({ - ...(pivotControls.imagePivot as SpriteAnchor), - x: percentToAnchor(value), - }) - } - onCancel={onTransformCancel} - prefix="X" - suffix="%" - ariaLabel={`${ - t('propertiesPanel.spriteImagePivot') || '축' - } X`} - width="100%" - min={0} - max={100} - allowDecimal - decimalScale={1} - /> - - pivotControls.onImagePivotCommit({ - ...(pivotControls.imagePivot as SpriteAnchor), - y: percentToAnchor(value), - }) - } - onPreview={(value) => - pivotControls.onImagePivotPreview({ - ...(pivotControls.imagePivot as SpriteAnchor), - y: percentToAnchor(value), - }) - } - onCancel={onTransformCancel} - prefix="Y" - suffix="%" - ariaLabel={`${ - t('propertiesPanel.spriteImagePivot') || '축' - } Y`} - width="100%" - min={0} - max={100} - allowDecimal - decimalScale={1} - /> -
- ) : null} -
-
- ) : null} - {/* 담당 키 - 미리보기·변환 아래가 정위치, 신규 상태도 카드가 작아 한눈에 보인다 */} {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? (

diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 082cbb8ff..16667aed4 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -50,7 +50,10 @@ import SmartGuidesOverlay from '../overlays/SmartGuidesOverlay'; import MarqueeSelectionOverlay from '../overlays/MarqueeSelectionOverlay'; import ResizeHandles from '../handles/ResizeHandles'; import GradientAxisOverlay from '../handles/GradientAxisHandle'; -import SpritePoseGizmo from '../handles/SpritePoseGizmo'; +import SpriteCanvasHandles from '../handles/SpriteCanvasHandles'; +import { SELECTION_BORDER_WIDTH } from '../handles/selectionOutline'; +import { matchSpriteAnchorPreset } from '@utils/sprite/spriteGeometry'; +import { useSpritePoseHandleStore } from '@stores/grid/useSpritePoseHandleStore'; import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; import GroupResizeHandles from '../handles/GroupResizeHandles'; import { @@ -260,6 +263,10 @@ const Grid = ({ const hasGradientEditSession = useGradientEditStore( (state) => state.session !== null, ); + // 스프라이트 자세 편집 중 여부 - 자세 프레임이 리사이즈 핸들을 대신한다 + const hasSpritePoseSession = useSpritePoseHandleStore( + (state) => state.session !== null, + ); const selectedElements = useGridSelectionStore( (state) => state.selectedElements, ); @@ -1727,11 +1734,11 @@ const Grid = ({ key={el.id} style={{ position: 'absolute', - left: displayBounds.x * zoom + panX - 2, - top: displayBounds.y * zoom + panY - 2, - width: displayBounds.width * zoom + 4, - height: displayBounds.height * zoom + 4, - border: '2px solid var(--ui-selection-border)', + left: displayBounds.x * zoom + panX - SELECTION_BORDER_WIDTH, + top: displayBounds.y * zoom + panY - SELECTION_BORDER_WIDTH, + width: displayBounds.width * zoom + SELECTION_BORDER_WIDTH * 2, + height: displayBounds.height * zoom + SELECTION_BORDER_WIDTH * 2, + border: `${SELECTION_BORDER_WIDTH}px solid var(--ui-selection-border)`, borderRadius: '4px', pointerEvents: 'none', zIndex: 'var(--z-canvas-selection-outline)', @@ -1787,6 +1794,23 @@ const Grid = ({ if (!bounds || !elementId) return null; if (hasGradientEditSession) return null; + // 자세 편집 중에는 자세 프레임이 핸들을 맡는다 - 상자 핸들과 겹치지 않게 + if (hasSpritePoseSession) return null; + + // 기준점 표식이 프리셋 자리에 앉아 있으면 그 자리 핸들은 표식에 자리를 내준다. + // 표식 히트 영역이 핸들을 덮어 어차피 잡히지 않고, 비율 고정이라 다른 핸들로 충분하다 + const spriteAtRest = + el.type === 'sprite' + ? spritePositions[selectedKeyType]?.find( + (candidate) => candidate.id === el.id, + ) ?? null + : null; + const occupiedHandle = + spriteAtRest && + Math.abs(spriteAtRest.idleTransform.x) < 1e-6 && + Math.abs(spriteAtRest.idleTransform.y) < 1e-6 + ? matchSpriteAnchorPreset(spriteAtRest.pivot) + : null; return ( ); })()} @@ -1836,8 +1863,15 @@ const Grid = ({ panX={panX} panY={panY} /> - {/* 온캔버스 스프라이트 자세 기즈모 - 자세 팝업이 열려 있을 때만 표시 */} - + {/* 온캔버스 스프라이트 핸들 - 선택 중엔 기준점, 자세 편집 중엔 자세 프레임 */} + {/* 우클릭 리스트 팝업 */}

{ const resizeRef = useRef({ isResizing: false, @@ -343,7 +350,7 @@ const ResizeHandles = ({ } const keepAspect = - !!moveEvent.shiftKey && + (lockAspect || !!moveEvent.shiftKey) && Number.isFinite(startAspectRatio) && startAspectRatio > 0 && Number.isFinite(startBounds!.width) && @@ -472,9 +479,8 @@ const ResizeHandles = ({ // 프리뷰 bounds가 있으면 프리뷰용으로 사용, 없으면 실제 bounds 사용 const displayBounds = previewBounds || bounds; - // 선택 테두리의 중심선 기준 좌표 (테두리 두께 2px의 중심 = 1px) - const borderThickness = 2; - const borderCenter = borderThickness / 2; // 테두리의 중심선까지의 거리 + // 선택 테두리의 중심선 기준 좌표 - 핸들은 테두리 선 위에 앉는다 + const borderCenter = SELECTION_BORDER_CENTER; const selectionLeft = displayBounds.x * zoom + panX - borderCenter; const selectionTop = displayBounds.y * zoom + panY - borderCenter; const selectionWidth = displayBounds.width * zoom + borderCenter * 2; @@ -482,7 +488,12 @@ const ResizeHandles = ({ return ( <> - {HANDLES.map((handle) => { + {HANDLES.filter( + (handle) => + !occupiedHandle || + handle.x !== occupiedHandle.x || + handle.y !== occupiedHandle.y, + ).map((handle) => { // 핸들 중심 위치 계산 (선택 테두리의 가장자리 중앙에 배치) const centerX = selectionLeft + selectionWidth * handle.x; const centerY = selectionTop + selectionHeight * handle.y; diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx index f47842f71..b0b2b3662 100644 --- a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx @@ -2,6 +2,7 @@ // 렌더 중 ref 대입(세션·지오메트리 최신화)이 React Compiler bailout이라 // 컴파일 대상에서 빠진다. 조용한 제외와 구분되게 명시한다 (GradientAxisHandle과 같은 이유) import React, { useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; import { useTranslation } from '@contexts/useTranslation'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { @@ -23,26 +24,48 @@ import { import { DEG_TO_RAD, RAD_TO_DEG, - SPRITE_ANCHOR_PRESETS, + isSameSpriteAnchor, + isSameSpriteTransform, spritePivotPx, } from '@utils/sprite/spriteGeometry'; -import { spritePivotChangePatch } from '@utils/sprite/spritePlacement'; +import { + compensateTransformForPivotDelta, + compensateTransformForPosePivotDelta, + isSameSpritePlacement, + pivotHandleLocalPoint, + pivotForHandleTarget, + placeSpriteVisual, + posePivotHandleLocalPoint, + posePivotForHandleTarget, + snapPivotToPreset, + snapPosePivotToPreset, + spriteIdleVisual, + spritePivotChangePatch, + spritePoseVisual, + type SpritePivotHandleFrame, + type SpritePosePivotHandleFrame, +} from '@utils/sprite/spritePlacement'; import { clamp } from '@utils/core/clamp'; import { suppressNextClick } from '@utils/dom/suppressNextClick'; import { createRafLatestScheduler } from '@utils/animation/rafLatestScheduler'; import { beginDragCursor, endDragCursor } from '@utils/core/dragCursor'; +import { getActiveElement } from '@utils/dom/activeElement'; +import { isHTMLElementNode } from '@utils/dom/isElementNode'; import { releaseDragSession, tryAcquireDragSession, } from '@hooks/Grid/dragSession'; +import { SELECTION_BORDER_CENTER } from './selectionOutline'; /** * 온캔버스 스프라이트 핸들. * 스프라이트가 선택돼 있으면 기준점 십자를 그리고 드래그로 옮긴다 (9점 자석 스냅, - * Ctrl/Cmd로 해제). 기준점을 옮겨도 그림은 움직이지 않는다 - 자세 이동값을 함께 보정. + * Ctrl/Cmd로 해제). 기본 그림과 독립 상태는 제자리에 두고 연결 상태는 새 축을 따른다. * 자세 팝업이 열려 있으면 자세 이미지 프레임을 그린다: 본체 드래그 = 위치, * 위쪽 노브 = 회전(Shift 15° 스냅), 모서리 = 배율(기준점 중심). - * 배치는 GradientAxisOverlay와 동일한 비스케일 오버레이 층 - zoom/pan을 직접 계산 + * 배치는 GradientAxisOverlay와 동일한 비스케일 오버레이 층 - zoom/pan을 직접 계산. + * 드래그 계산은 전부 요소 로컬 px에서 하므로 도중에 팬·줌이 바뀌어도 포인터가 + * 가리키는 자리를 그대로 따라간다 */ interface SpriteCanvasHandlesProps { @@ -61,43 +84,95 @@ interface Point { y: number; } +type PivotDragBase = + | { + kind: 'base'; + mode: string; + id: string; + canonical: ReactiveSpritePosition; + frame: SpritePivotHandleFrame; + grabOffset: Point; + } + | { + kind: 'pose'; + mode: string; + id: string; + canonical: ReactiveSpritePosition; + poseId: string; + frame: SpritePosePivotHandleFrame; + grabOffset: Point; + }; + interface DragState { kind: DragKind; pointerId: number; - start: Point; + /** buttons가 유효했던 이동을 본 뒤 0으로 바뀌면 누락된 up으로 판정 */ + sawPressedMove: boolean; + /** 드래그 시작 포인터 (요소 로컬 px) */ + startLocal: Point; /** 드래그 시작 시점 자세 transform (자세 핸들) */ baseTransform: SpriteTransform; - /** 시작 시점 세션 - 스토어 세션이 먼저 닫혀도 cancel 배선을 유지한다 */ - session: SpritePoseHandleSession | null; - /** 기준점 드래그의 canonical 스프라이트와 문서 좌표 */ - pivotBase: { - mode: string; - id: string; - canonical: ReactiveSpritePosition; - /** 십자가 따라가는 이동값 - 자세 편집 중이면 그 자세의 값 */ - translate: Point; - } | null; - /** 회전 축의 화면 좌표와 시작 각도·거리 */ - axisScreen: Point; + /** 회전·배율 축의 로컬 위치 - 시작 시점 이동값이 더해진 P */ + axisLocal: Point; startAngle: number; startDistance: number; + /** 시작 시점 세션 - 스토어 세션이 먼저 닫혀도 cancel 배선을 유지한다 */ + session: SpritePoseHandleSession | null; + /** 기준점 드래그의 canonical 스프라이트 - 도중에 바뀌면 드래그를 포기한다 */ + pivotBase: PivotDragBase | null; + /** 자석에 붙은 프리셋 - 더 넓은 이탈 반경까지 유지해 경계 떨림 방지 */ + snappedPivot: SpriteAnchor | null; +} + +interface PendingBasePivotLanding { + generation: number; + mode: string; + id: string; + pivot: SpriteAnchor; } const KNOB_HIT_SIZE = 22; const PIVOT_SNAP_PX = 8; +const PIVOT_SNAP_RELEASE_PX = 12; +// 기준점 표식 - 포커스 브래킷: 가운데 점을 네 귀퉁이 꺾쇠가 감싼다. 꺾쇠는 대각선 +// 방향으로만 뻗어 선택 테두리와 겹치지 않고, 호버·드래그에서 안쪽으로 조여든다 +const PIVOT_HIT_SIZE = 26; +const PIVOT_CENTER = PIVOT_HIT_SIZE / 2; +const PIVOT_BRACKET_REACH = 8; +const PIVOT_BRACKET_ARM = 4.5; +const PIVOT_BRACKET_ACTIVE_SCALE = 0.8125; +const PIVOT_DOT_RADIUS = 1.75; +const PIVOT_DOT_ACTIVE_RADIUS = 2; +const PIVOT_BRACKETS = (() => { + const near = PIVOT_CENTER - PIVOT_BRACKET_REACH; + const far = PIVOT_CENTER + PIVOT_BRACKET_REACH; + const arm = PIVOT_BRACKET_ARM; + return [ + `M${near} ${near + arm}V${near}H${near + arm}`, + `M${far - arm} ${near}H${far}V${near + arm}`, + `M${far} ${far - arm}V${far}H${far - arm}`, + `M${near + arm} ${far}H${near}V${far - arm}`, + ].join(''); +})(); const ROTATE_KNOB_OFFSET_PX = 22; const ROTATE_SNAP_DEG = 15; // 축과 겹친 모서리는 배율 방향이 정의되지 않아 잡지 않는다 const MIN_SCALE_ARM_PX = 2; -const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); - // atan2 차(-360~360도)를 최단 호 표현(-180~180]으로 접는다 const wrapDegrees = (deg: number): number => { const wrapped = ((deg + 540) % 360) - 180; return wrapped === -180 ? 180 : wrapped; }; +const findCanonicalSprite = ( + mode: string, + id: string, +): ReactiveSpritePosition | null => + useSpriteStore + .getState() + .positions[mode]?.find((candidate) => candidate.id === id) ?? null; + const SpriteCanvasHandles = ({ spritePositions, selectedElements, @@ -121,12 +196,24 @@ const SpriteCanvasHandles = ({ const lastPivotPatchRef = useRef | null>(null); + const lastPosePivotRef = useRef<{ + pivot: SpriteAnchor; + transform: SpriteTransform; + } | null>(null); + const pendingBasePivotGenerationRef = useRef(0); + const [pendingBasePivotLanding, setPendingBasePivotLanding] = + useState(null); // 드래그 중 로컬 표시값 - preview 채널과 별개로 핸들을 즉시 따라가게 한다 const [dragTransform, setDragTransform] = useState( null, ); const [dragPivot, setDragPivot] = useState(null); + // 기준점 드래그 중 표식이 놓일 보정 transform - 오버레이 재합성을 기다리지 않고 + // 표식이 포인터 아래에 바로 놓이게 한다 (자세 편집 중엔 세션 값이 보정되지 않아 필수) + const [dragPivotTransform, setDragPivotTransform] = + useState(null); + const [pivotHover, setPivotHover] = useState(false); const selected = selectedElements.length === 1 ? selectedElements[0] : null; const sprite = @@ -136,7 +223,7 @@ const SpriteCanvasHandles = ({ ) ?? null : null; - // 드래그 도중 휠 팬·줌이 좌표를 움직여도 매 이벤트에서 최신 지오메트리로 + // 드래그 도중 휠 팬·줌이 좌표를 움직여도 매 이벤트에서 최신 뷰로 // 재계산한다 (GradientAxisHandle과 같은 이유의 렌더 중 ref 대입) const viewRef = useRef({ zoom, panX, panY }); // eslint-disable-next-line react-hooks/refs @@ -155,8 +242,18 @@ const SpriteCanvasHandles = ({ : null; useEffect(() => { if (dragRef.current) cancelDragRef.current?.(); + if (ownerKey === null) { + pendingBasePivotGenerationRef.current += 1; + setPendingBasePivotLanding(null); + } }, [ownerKey]); - useEffect(() => () => cancelDragRef.current?.(), []); + useEffect( + () => () => { + pendingBasePivotGenerationRef.current += 1; + cancelDragRef.current?.(); + }, + [], + ); // undo/redo 반영은 대상도 세대도 바꾸지 않아 위 배선에 걸리지 않는다. 그대로 두면 // 저장값이 되돌아간 뒤 pointerup이 시작 시점 값으로 푼 결과를 다시 커밋한다 @@ -166,13 +263,21 @@ const SpriteCanvasHandles = ({ if (historyTickRef.current === historyTick) return; historyTickRef.current = historyTick; if (dragRef.current) cancelDragRef.current?.(); + pendingBasePivotGenerationRef.current += 1; + setPendingBasePivotLanding(null); }, [historyTick]); if (!sprite && !session) { - if (dragTransform !== null || dragPivot !== null) { + if ( + dragTransform !== null || + dragPivot !== null || + dragPivotTransform !== null || + pendingBasePivotLanding !== null + ) { // 대상이 사라지면 로컬 드래그 상태도 함께 비운다 (렌더 중 보정 패턴) setDragTransform(null); setDragPivot(null); + setDragPivotTransform(null); } return null; } @@ -182,21 +287,61 @@ const SpriteCanvasHandles = ({ const box = session ? { width: session.width, height: session.height } : { width: sprite!.width, height: sprite!.height }; - const pivot = dragPivot ?? (session ? session.pivot : sprite!.pivot); + const visiblePendingBasePivot = + !session && + pendingBasePivotLanding?.mode === selectedKeyType && + pendingBasePivotLanding.id === sprite?.id + ? spritePivotChangePatch(sprite!, pendingBasePivotLanding.pivot) + : null; + const imagePivot = + dragPivot ?? + visiblePendingBasePivot?.pivot ?? + (session ? session.imagePivot : sprite!.pivot); + const axisPivot = session ? session.pivot : imagePivot; const displayTransform = - dragTransform ?? (session ? session.transform : sprite!.idleTransform); - const axis = spritePivotPx({ ...box, pivot }); + dragPivotTransform ?? + dragTransform ?? + visiblePendingBasePivot?.idleTransform ?? + (session ? session.transform : sprite!.idleTransform); + const axis = spritePivotPx({ ...box, pivot: axisPivot }); const toScreen = (local: Point): Point => ({ x: (origin.dx + local.x) * zoom + panX, y: (origin.dy + local.y) * zoom + panY, }); // 십자는 화면에서 실제로 회전이 일어나는 점 - 축에 이동값이 더해진다 - const pivotScreen = toScreen({ + const pivotLocal = { x: axis.x + displayTransform.x, y: axis.y + displayTransform.y, - }); + }; + // 선택 테두리와 리사이즈 핸들은 상자 밖 1px 프레임에 그려진다. 상자 핸들이 보이는 동안은 + // 표식도 그 프레임에 앉혀 모서리·변에서 선·핸들 중심과 정확히 겹치게 한다. + // 자세 편집 중에는 자세 프레임 선이 상자 그대로라 보정하지 않는다 + const frameInset = session ? 0 : SELECTION_BORDER_CENTER; + const pivotAxisScreen = toScreen(pivotLocal); + const pivotScreen = { + x: pivotAxisScreen.x + (2 * axisPivot.x - 1) * frameInset, + y: pivotAxisScreen.y + (2 * axisPivot.y - 1) * frameInset, + }; + + const posePlacement = session + ? { + rect: { + ...session.placement.rect, + x: + session.placement.rect.x - + (imagePivot.x - session.imagePivot.x) * + session.placement.rect.width, + y: + session.placement.rect.y - + (imagePivot.y - session.imagePivot.y) * + session.placement.rect.height, + }, + pivot: imagePivot, + } + : null; + // 뷰(zoom·pan)는 ref에서 읽는다 - 드래그 중 최신 값이어야 팬·줌을 따라간다 const clientToLocal = (clientX: number, clientY: number): Point | null => { const hostRect = rootRef.current?.getBoundingClientRect(); if (!hostRect) return null; @@ -207,17 +352,11 @@ const SpriteCanvasHandles = ({ }; }; - const clientToScreen = (clientX: number, clientY: number): Point | null => { - const hostRect = rootRef.current?.getBoundingClientRect(); - if (!hostRect) return null; - return { x: clientX - hostRect.left, y: clientY - hostRect.top }; - }; - // 자세 프레임 - 배치 rect의 네 모서리에 자세 변환을 적용한 화면 폴리곤. // 한 점 u는 t + P + sR(u − P)에 놓인다 const poseCorners = (() => { if (!session) return null; - const { rect } = session.placement; + const { rect } = posePlacement!; const rad = displayTransform.rotation * DEG_TO_RAD; const cos = Math.cos(rad) * displayTransform.scale; const sin = Math.sin(rad) * displayTransform.scale; @@ -251,11 +390,12 @@ const SpriteCanvasHandles = ({ const ux = top.x - bottom.x; const uy = top.y - bottom.y; const length = Math.hypot(ux, uy); - if (length < 1e-3) + if (length < 1e-3) { return { anchor: top, knob: { x: top.x, y: top.y - ROTATE_KNOB_OFFSET_PX }, }; + } return { anchor: top, knob: { @@ -266,8 +406,48 @@ const SpriteCanvasHandles = ({ })(); const generationNow = () => useSpritePoseHandleStore.getState().generation; - // 드래그 시작 때 잡은 세대가 아직 유효한지 - preview·커밋의 공통 전제 + // 드래그 시작 때 잡은 세대가 아직 유효한지 - 자세 preview·커밋의 공통 전제 const ownsSession = () => ownerGenerationRef.current === generationNow(); + // 기준점 드래그의 전제 - 시작 시점 canonical이 아직 스토어의 그 객체인지. + // 리사이즈 착지·다른 창 편집·undo가 끼어들면 시작 시점 patch는 낡은 값이다 + const pivotBaseCurrent = (drag: DragState): boolean => { + const base = drag.pivotBase; + if (!base) return false; + const current = findCanonicalSprite(base.mode, base.id); + if (!current) return false; + if (base.kind === 'base') { + const placement = placeSpriteVisual(current, spriteIdleVisual(current)); + return ( + current.dx === base.canonical.dx && + current.dy === base.canonical.dy && + current.width === base.frame.box.width && + current.height === base.frame.box.height && + isSameSpriteAnchor(current.pivot, base.frame.pivot) && + isSameSpriteTransform(current.idleTransform, base.frame.transform) && + placement.rect.width === base.frame.rect.width && + placement.rect.height === base.frame.rect.height + ); + } + const pose = current.poses.find( + (candidate) => candidate.poseId === base.poseId, + ); + if (!pose) return false; + const placement = placeSpriteVisual( + current, + spritePoseVisual(current, pose), + ); + const axis = spritePivotPx(current); + return ( + current.dx === base.canonical.dx && + current.dy === base.canonical.dy && + axis.x === base.frame.axis.x && + axis.y === base.frame.axis.y && + isSameSpriteAnchor(pose.pivot ?? current.pivot, base.frame.pivot) && + isSameSpriteTransform(pose.transform, base.frame.transform) && + placement.rect.width === base.frame.rect.width && + placement.rect.height === base.frame.rect.height + ); + }; const releaseGrabbed = () => { const grabbed = grabbedRef.current; @@ -285,118 +465,148 @@ const SpriteCanvasHandles = ({ if (!dragRef.current) return; cancelDragRef.current = null; releaseDragSession(); - releaseGrabbed(); moveSchedulerRef.current?.cancel(); moveSchedulerRef.current = null; detachRef.current?.(); detachRef.current = null; + releaseGrabbed(); dragRef.current = null; setDragTransform(null); setDragPivot(null); + setDragPivotTransform(null); + // 캡처 중 leave가 유실되면 호버 채움이 남는다 + setPivotHover(false); lastTransformRef.current = null; lastPivotPatchRef.current = null; + lastPosePivotRef.current = null; suppressNextClick(); }; const cancelActiveDrag = () => { const drag = dragRef.current; if (!drag) return; - // 스토어 세션이 먼저 닫혔어도 시작 시점 세션으로 preview 제스처를 닫는다 - if (drag.kind === 'pivot') editGestureController.cancel(); - else drag.session?.cancel(); + // 기준점은 드래그 중 저장 프리뷰를 만들지 않아 정리할 세션이 없다 + if (drag.kind !== 'pivot') { + drag.session?.cancel(); + } finishDrag(); }; - // 기준점 드래그 - 포인터를 이미지 정규화 좌표로 되돌리고 9점에 자석 스냅 + // 기준점 드래그 - 표식이 포인터(잡은 자리 보정) 아래에 오도록 역변환으로 기준점을 + // 구한다. 표식은 P + t + sR·Δ에 놓이므로 회전·배율이 있어도 포인터를 그대로 따라간다. + // 9점 프리셋은 각 프리셋일 때 표식이 놓일 자리와 화면 px로 비교해 자석 스냅한다 - + // 줌과 무관하게 같은 손맛이다 const pivotFromPointer = ( drag: DragState, - clientX: number, - clientY: number, + local: Point, snapDisabled: boolean, ): SpriteAnchor | null => { - const local = clientToLocal(clientX, clientY); - const screen = clientToScreen(clientX, clientY); const base = drag.pivotBase; - if (!local || !screen || !base) return null; - const { width, height } = base.canonical; - const next: SpriteAnchor = { - x: clamp01((local.x - base.translate.x) / width), - y: clamp01((local.y - base.translate.y) / height), + if (!base) return null; + const target = { + x: local.x - base.grabOffset.x, + y: local.y - base.grabOffset.y, }; - if (snapDisabled) return next; - const view = viewRef.current; - for (const preset of SPRITE_ANCHOR_PRESETS) { - const presetScreen = { - x: - (base.canonical.dx + base.translate.x + preset.x * width) * - view.zoom + - view.panX, - y: - (base.canonical.dy + base.translate.y + preset.y * height) * - view.zoom + - view.panY, - }; - if ( - Math.hypot(presetScreen.x - screen.x, presetScreen.y - screen.y) <= - PIVOT_SNAP_PX - ) { - return { x: preset.x, y: preset.y }; + const next = + base.kind === 'pose' + ? posePivotForHandleTarget(base.frame, target) + : pivotForHandleTarget(base.frame, target); + if (snapDisabled) { + drag.snappedPivot = null; + return next; + } + const held = drag.snappedPivot; + if (held) { + const at = + base.kind === 'pose' + ? posePivotHandleLocalPoint(base.frame, held) + : pivotHandleLocalPoint(base.frame, held); + const releaseRadius = PIVOT_SNAP_RELEASE_PX / viewRef.current.zoom; + if (Math.hypot(at.x - target.x, at.y - target.y) <= releaseRadius) { + return held; } + drag.snappedPivot = null; } - return next; + const snapped = + base.kind === 'pose' + ? snapPosePivotToPreset( + base.frame, + target, + PIVOT_SNAP_PX / viewRef.current.zoom, + ) + : snapPivotToPreset( + base.frame, + target, + PIVOT_SNAP_PX / viewRef.current.zoom, + ); + drag.snappedPivot = snapped; + return snapped ?? next; }; - const previewPivotPatch = (drag: DragState, next: SpriteAnchor) => { + const updatePivotDrag = (drag: DragState, next: SpriteAnchor) => { const base = drag.pivotBase; if (!base) return; + if (base.kind === 'pose') { + const display = compensateTransformForPosePivotDelta(base.frame, next); + if (!display) return; + lastPosePivotRef.current = { pivot: next, transform: display }; + setDragPivot(next); + setDragPivotTransform(display); + return; + } + // 보정이 이동값 범위를 넘는 자리는 그림이 움직이므로 받지 않는다 - + // 마지막으로 가능했던 자리에 머문다 + // 커밋 patch(canonical 기준)와 표식 보정(표시 프레임 기준)이 둘 다 성립할 때만 발행 const patch = spritePivotChangePatch(base.canonical, next); + const display = compensateTransformForPivotDelta(base.frame, next); + if (!patch || !display) return; lastPivotPatchRef.current = patch; setDragPivot(next); - editGestureController.preview(base.mode, [{ id: base.id, patch }], { - domain: 'spritePosition', - }); + setDragPivotTransform(display); }; const applyWindowMove = (event: PointerEvent) => { const drag = dragRef.current; if (!drag) return; + const local = clientToLocal(event.clientX, event.clientY); + if (!local) return; if (drag.kind === 'pivot') { + if (!pivotBaseCurrent(drag)) { + cancelActiveDrag(); + return; + } const next = pivotFromPointer( drag, - event.clientX, - event.clientY, + local, event.ctrlKey || event.metaKey, ); - if (next) previewPivotPatch(drag, next); + if (next) updatePivotDrag(drag, next); return; } const live = sessionRef.current; if (!live) return; // 소유권이 갈린 뒤의 move는 이미 취소된 preview를 되열 뿐이라 버린다 if (!ownsSession()) return; - const screen = clientToScreen(event.clientX, event.clientY); - if (!screen) return; const { offset, rotation, scale } = SPRITE_CONSTRAINTS; let next: SpriteTransform; if (drag.kind === 'move') { - const view = viewRef.current; next = { ...drag.baseTransform, x: clamp( - drag.baseTransform.x + (screen.x - drag.start.x) / view.zoom, + drag.baseTransform.x + (local.x - drag.startLocal.x), offset.min, offset.max, ), y: clamp( - drag.baseTransform.y + (screen.y - drag.start.y) / view.zoom, + drag.baseTransform.y + (local.y - drag.startLocal.y), offset.min, offset.max, ), }; } else if (drag.kind === 'rotate') { const angle = Math.atan2( - screen.y - drag.axisScreen.y, - screen.x - drag.axisScreen.x, + local.y - drag.axisLocal.y, + local.x - drag.axisLocal.x, ); let deg = wrapDegrees( drag.baseTransform.rotation + (angle - drag.startAngle) * RAD_TO_DEG, @@ -410,8 +620,8 @@ const SpriteCanvasHandles = ({ }; } else { const distance = Math.hypot( - screen.x - drag.axisScreen.x, - screen.y - drag.axisScreen.y, + local.x - drag.axisLocal.x, + local.y - drag.axisLocal.y, ); next = { ...drag.baseTransform, @@ -427,29 +637,66 @@ const SpriteCanvasHandles = ({ live.preview(next); }; - const handleWindowUp = (event: PointerEvent) => { + const settleActiveDrag = (pointerId: number) => { const drag = dragRef.current; if (!drag) return; // 다른 포인터의 up이 첫 드래그를 커밋하지 못하게 한다 (멀티터치) - if (event.pointerId !== drag.pointerId) return; + if (pointerId !== drag.pointerId) return; if (drag.kind === 'pivot') { + // 시작 시점 canonical이 바뀌었으면 낡은 patch라 커밋하지 않는다 + if (!pivotBaseCurrent(drag)) { + cancelActiveDrag(); + return; + } moveSchedulerRef.current?.flush(); - const patch = lastPivotPatchRef.current; const base = drag.pivotBase; - if (patch && base) { + if (base?.kind === 'pose') { + const next = lastPosePivotRef.current; + if (next && ownsSession()) { + drag.session?.commitPivot(next.pivot, next.transform); + } + finishDrag(); + return; + } + const patch = lastPivotPatchRef.current; + if (patch && base && pivotBaseCurrent(drag)) { + const landingGeneration = pendingBasePivotGenerationRef.current + 1; + pendingBasePivotGenerationRef.current = landingGeneration; + setPendingBasePivotLanding({ + generation: landingGeneration, + mode: base.mode, + id: base.id, + pivot: patch.pivot, + }); const gestureId = editGestureController.activeGestureId() ?? undefined; const persisted = spriteItemsApi.patchPosition( base.mode, base.id, patch, gestureId, + (current) => spritePivotChangePatch(current, patch.pivot), ); editGestureController.settleCommit(persisted); - void persisted.catch((error) => { - console.error('Failed to move sprite pivot', error); - }); - } else { - editGestureController.cancel(); + void persisted.then( + (result) => { + if (pendingBasePivotGenerationRef.current === landingGeneration) { + setPendingBasePivotLanding(null); + } + if (result === 'skipped') { + console.warn( + 'Sprite pivot change skipped against the latest base', + base.id, + patch.pivot, + ); + } + }, + (error) => { + if (pendingBasePivotGenerationRef.current === landingGeneration) { + setPendingBasePivotLanding(null); + } + console.error('Failed to move sprite pivot', error); + }, + ); } finishDrag(); return; @@ -468,31 +715,68 @@ const SpriteCanvasHandles = ({ finishDrag(); }; + const handleWindowUp = (event: PointerEvent) => { + settleActiveDrag(event.pointerId); + }; + const handleWindowMove = (event: PointerEvent) => { - if (event.pointerId !== dragRef.current?.pointerId) return; - // 창 밖에서 버튼이 이미 떼졌으면 stale 드래그 - 커밋 없이 종료 - if (event.buttons === 0) { - cancelActiveDrag(); - return; - } + const drag = dragRef.current; + if (event.pointerId !== drag?.pointerId) return; + if (event.buttons !== 0) drag.sawPressedMove = true; moveSchedulerRef.current?.push(event); + // 일부 WebView 합성 입력은 up 없이 마지막 move의 buttons만 0으로 바뀐다 + // 처음부터 buttons가 0인 합성 드래그는 기존처럼 명시적 up까지 유지한다 + if (event.buttons === 0 && drag.sawPressedMove) { + settleActiveDrag(event.pointerId); + } }; const handleWindowCancel = (event: PointerEvent) => { if (event.pointerId !== dragRef.current?.pointerId) return; cancelActiveDrag(); }; + const handleMouseUp = (event: MouseEvent) => { + if (event.button !== 0) return; + const drag = dragRef.current; + if (!drag) return; + // 일부 WebView는 마우스 해제를 pointerup으로 올리지 않는다 + settleActiveDrag(drag.pointerId); + }; + const handleLostPointerCapture = (event: Event) => { + const pointerEvent = event as PointerEvent; + if (pointerEvent.pointerId !== dragRef.current?.pointerId) return; + // 캡처 해제 시점의 buttons는 엔진마다 달라 마지막 유효 위치를 확정한다 + // 명시적인 중단은 pointercancel과 blur가 맡는다 + settleActiveDrag(pointerEvent.pointerId); + }; const handleWindowBlur = () => cancelActiveDrag(); const attachWindowListeners = () => { - window.addEventListener('pointermove', handleWindowMove); - window.addEventListener('pointerup', handleWindowUp); - window.addEventListener('pointercancel', handleWindowCancel); - window.addEventListener('blur', handleWindowBlur); + const captureTarget = grabbedRef.current?.el ?? null; + const ownerDocument = captureTarget?.ownerDocument ?? document; + const ownerWindow = ownerDocument.defaultView ?? window; + ownerWindow.addEventListener('pointermove', handleWindowMove, true); + ownerWindow.addEventListener('pointerup', handleWindowUp, true); + ownerWindow.addEventListener('pointercancel', handleWindowCancel, true); + ownerWindow.addEventListener('mouseup', handleMouseUp, true); + ownerWindow.addEventListener('blur', handleWindowBlur); + captureTarget?.addEventListener( + 'lostpointercapture', + handleLostPointerCapture, + ); detachRef.current = () => { - window.removeEventListener('pointermove', handleWindowMove); - window.removeEventListener('pointerup', handleWindowUp); - window.removeEventListener('pointercancel', handleWindowCancel); - window.removeEventListener('blur', handleWindowBlur); + ownerWindow.removeEventListener('pointermove', handleWindowMove, true); + ownerWindow.removeEventListener('pointerup', handleWindowUp, true); + ownerWindow.removeEventListener( + 'pointercancel', + handleWindowCancel, + true, + ); + ownerWindow.removeEventListener('mouseup', handleMouseUp, true); + ownerWindow.removeEventListener('blur', handleWindowBlur); + captureTarget?.removeEventListener( + 'lostpointercapture', + handleLostPointerCapture, + ); }; }; @@ -507,18 +791,29 @@ const SpriteCanvasHandles = ({ grabbedRef.current = { el, pointerId: event.pointerId }; }; + const settleFocusedField = () => { + const active = getActiveElement(); + if ( + isHTMLElementNode(active) && + active.matches('input, textarea, [contenteditable="true"]') + ) { + flushSync(() => active.blur()); + } + }; + const beginPoseDrag = ( event: React.PointerEvent, kind: Exclude, ) => { if (event.button !== 0 || dragRef.current || !session) return; - const screen = clientToScreen(event.clientX, event.clientY); - if (!screen) return; + settleFocusedField(); + const local = clientToLocal(event.clientX, event.clientY); + if (!local) return; const startDistance = Math.hypot( - screen.x - pivotScreen.x, - screen.y - pivotScreen.y, + local.x - pivotLocal.x, + local.y - pivotLocal.y, ); - if (kind === 'scale' && startDistance < MIN_SCALE_ARM_PX) return; + if (kind === 'scale' && startDistance * zoom < MIN_SCALE_ARM_PX) return; event.preventDefault(); event.stopPropagation(); if (!tryAcquireDragSession()) return; @@ -527,70 +822,180 @@ const SpriteCanvasHandles = ({ dragRef.current = { kind, pointerId: event.pointerId, - start: screen, + sawPressedMove: false, + startLocal: local, baseTransform: session.transform, + axisLocal: pivotLocal, + startAngle: Math.atan2(local.y - pivotLocal.y, local.x - pivotLocal.x), + startDistance, session, pivotBase: null, - axisScreen: pivotScreen, - startAngle: Math.atan2( - screen.y - pivotScreen.y, - screen.x - pivotScreen.x, - ), - startDistance, + snappedPivot: null, }; cancelDragRef.current = cancelActiveDrag; moveSchedulerRef.current = createRafLatestScheduler(applyWindowMove); attachWindowListeners(); }; + // 표시 상태가 canonical과 어긋나 드래그를 거부할 때는 이유를 남긴다 - 조용히 죽은 + // 핸들처럼 보이지 않게 + const refusePivotDrag = (reason: string) => { + console.warn('Sprite pivot drag refused:', reason); + }; + const beginPivotDrag = (event: React.PointerEvent) => { if (event.button !== 0 || dragRef.current) return; + settleFocusedField(); const id = session ? session.positionId : sprite?.id; if (!id || !isNativeElementId(id)) return; const locator = resolveElementById('sprite', id); if (!locator) return; - const canonical = useSpriteStore - .getState() - .positions[locator.mode]?.find((candidate) => candidate.id === id); + const canonical = findCanonicalSprite(locator.mode, id); if (!canonical) return; - const screen = clientToScreen(event.clientX, event.clientY); - if (!screen) return; + // 컨트롤러 소유권이 사라진 로컬 프리뷰는 새 조작보다 먼저 회수한다 + const reclaimedOrphanPreview = session + ? false + : editGestureController.discardOrphanedLocalPreviews(); + // 상자 크기는 역변환의 분모라 표시 크기가 canonical과 다르면 기준점이 틀어진다 + if ( + !reclaimedOrphanPreview && + (box.width !== canonical.width || box.height !== canonical.height) + ) { + refusePivotDrag('displayed box differs from canonical'); + return; + } + let pivotBase: PivotDragBase; + if (session) { + const canonicalPose = canonical.poses.find( + (candidate) => candidate.poseId === session.poseId, + ); + if (!canonicalPose) { + refusePivotDrag('pose missing in canonical'); + return; + } + const canonicalImagePivot = canonicalPose.pivot ?? canonical.pivot; + if ( + !isSameSpriteAnchor(session.pivot, canonical.pivot) || + !isSameSpriteAnchor(imagePivot, canonicalImagePivot) + ) { + refusePivotDrag('session pivot differs from canonical'); + return; + } + if (!isSameSpriteTransform(session.transform, canonicalPose.transform)) { + refusePivotDrag('pose draft transform differs from canonical'); + return; + } + const canonicalPlacement = placeSpriteVisual( + canonical, + spritePoseVisual(canonical, canonicalPose), + ); + if (!isSameSpritePlacement(session.placement, canonicalPlacement)) { + refusePivotDrag('pose placement differs from canonical'); + return; + } + pivotBase = { + kind: 'pose', + mode: locator.mode, + id, + canonical, + poseId: canonicalPose.poseId, + frame: { + axis: spritePivotPx(canonical), + rect: canonicalPlacement.rect, + pivot: canonicalImagePivot, + transform: displayTransform, + }, + grabOffset: { x: 0, y: 0 }, + }; + } else { + if ( + !reclaimedOrphanPreview && + !isSameSpriteAnchor(imagePivot, canonical.pivot) + ) { + refusePivotDrag('displayed pivot differs from canonical'); + return; + } + if ( + !reclaimedOrphanPreview && + !isSameSpriteTransform(displayTransform, canonical.idleTransform) + ) { + refusePivotDrag('displayed transform differs from canonical'); + return; + } + const rect = placeSpriteVisual( + canonical, + spriteIdleVisual(canonical), + ).rect; + pivotBase = { + kind: 'base', + mode: locator.mode, + id, + canonical, + frame: { + box: { width: canonical.width, height: canonical.height }, + rect, + pivot: canonical.pivot, + transform: canonical.idleTransform, + }, + grabOffset: { x: 0, y: 0 }, + }; + } + const local = clientToLocal(event.clientX, event.clientY); + if (!local) return; event.preventDefault(); event.stopPropagation(); if (!tryAcquireDragSession()) return; grab(event, 'grabbing'); + pivotBase.grabOffset = { + x: local.x - pivotLocal.x, + y: local.y - pivotLocal.y, + }; + if (session) ownerGenerationRef.current = generationNow(); dragRef.current = { kind: 'pivot', pointerId: event.pointerId, - start: screen, - baseTransform: displayTransform, - session: null, - pivotBase: { - mode: locator.mode, - id, - canonical, - translate: { x: displayTransform.x, y: displayTransform.y }, - }, - axisScreen: pivotScreen, + sawPressedMove: false, + startLocal: local, + baseTransform: pivotBase.frame.transform, + axisLocal: pivotLocal, startAngle: 0, startDistance: 0, + session, + pivotBase, + snappedPivot: null, }; + if (reclaimedOrphanPreview && pivotBase.kind === 'base') { + const recoveredPivot = pivotForHandleTarget(pivotBase.frame, pivotLocal); + const recoveredTransform = compensateTransformForPivotDelta( + pivotBase.frame, + recoveredPivot, + ); + if (recoveredTransform) { + setDragPivot(recoveredPivot); + setDragPivotTransform(recoveredTransform); + } + } cancelDragRef.current = cancelActiveDrag; moveSchedulerRef.current = createRafLatestScheduler(applyWindowMove); attachWindowListeners(); }; - const knobStyle = (center: Point, cursor: string): React.CSSProperties => ({ + const knobStyle = ( + center: Point, + cursor: string, + size = KNOB_HIT_SIZE, + ): React.CSSProperties => ({ position: 'absolute', - left: center.x - KNOB_HIT_SIZE / 2, - top: center.y - KNOB_HIT_SIZE / 2, - width: KNOB_HIT_SIZE, - height: KNOB_HIT_SIZE, + left: center.x - size / 2, + top: center.y - size / 2, + width: size, + height: size, cursor, display: 'flex', alignItems: 'center', justifyContent: 'center', }); + const pivotActive = pivotHover || dragPivot !== null; const knobDotStyle: React.CSSProperties = { width: 10, height: 10, @@ -675,7 +1080,7 @@ const SpriteCanvasHandles = ({
) : null} - {/* 기준점 십자 - 드래그로 옮긴다. 그림은 움직이지 않는다 */} + {/* 기준점 표식 - 드래그로 옮긴다. 그림은 움직이지 않는다 */}
event.preventDefault()} + onPointerEnter={() => setPivotHover(true)} + onPointerLeave={() => setPivotHover(false)} className="pointer-events-auto" style={{ - ...knobStyle(pivotScreen, 'grab'), - color: 'var(--ui-selection-border)', - opacity: dragPivot ? 1 : 0.85, + ...knobStyle(pivotScreen, 'grab', PIVOT_HIT_SIZE), + color: pivotActive + ? 'var(--ui-selection)' + : 'var(--ui-selection-border-strong)', }} data-sprite-pivot-handle="true" > - - +
diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts index 8a0c928e7..a6cd0d416 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts @@ -64,7 +64,7 @@ export function isElementResizable( } else if (element.type === 'knob') { return true; } else if (element.type === 'sprite') { - // 리사이즈는 활동 영역 박스만 조정한다 - imageRect·poses는 불변 + // 리사이즈는 resizeSprite op - 상자와 자세 이동값이 함께 비례 스케일된다 return true; } else if (element.type === 'plugin') { const pluginEl = pluginElements.find( diff --git a/src/renderer/components/main/Grid/handles/selectionOutline.ts b/src/renderer/components/main/Grid/handles/selectionOutline.ts new file mode 100644 index 000000000..ddb2a4b15 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/selectionOutline.ts @@ -0,0 +1,4 @@ +// 선택 테두리 규격 - 상자 바깥쪽으로 2px를 두르고 선 중심은 가장자리에서 1px 바깥이다. +// 테두리·리사이즈 핸들·스프라이트 기준점 표식이 같은 프레임에 놓이도록 한 곳에서 정한다 +export const SELECTION_BORDER_WIDTH = 2; +export const SELECTION_BORDER_CENTER = SELECTION_BORDER_WIDTH / 2; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index 2ccbb5e4b..7d8d23a52 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useGridItemInteraction } from '@hooks/Grid/useGridItemInteraction'; import { useSpriteEditPreview } from '@stores/grid/useSpriteEditPreviewStore'; +import { isSameSpriteTransform } from '@utils/sprite/spriteGeometry'; import { isErrorForCurrentSrc, useFailedImageSrcs, @@ -14,28 +15,16 @@ import { spriteIdleVisual, spritePoseVisual, } from '@utils/sprite/spritePlacement'; -import { anchorPx } from '@utils/sprite/spriteGeometry'; -import { - activityAreaGuideMetrics, - editorChromeScale, -} from '@utils/grid/activityAreaGuide'; import { DEFAULT_SPRITE_SIZE } from '@src/types/key/sprites'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import type { GridItemProps } from './gridItemProps'; -// 활동 영역은 에디터 전용 가이드 - 오버레이에는 그리지 않는다. -// 이미지가 있으면 점선이 소음이라 호버에만, 없으면 가이드가 유일한 실체라 상시. -// 선택 상태는 그리드 공통 아웃라인이 전담한다 - 여기서 또 그리면 이중선이 된다. -// 호버 판정은 루트(group) 기준 - pointerdown 캡처가 자식 :hover를 끊어 -// 클릭 순간 점선이 한 프레임 꺼지는 깜빡임을 막는다 -const GUIDE_BORDER_CLASS = { - hoverOnly: - 'border-dashed border-transparent group-hover/sprite:border-[color:var(--ui-guide-activity)]', - always: 'border-dashed border-[color:var(--ui-guide-activity)]', -} as const; +// 자세 편집 중 기본 자세를 뒤에 남기는 고스트의 투명도 +const IDLE_GHOST_OPACITY = 0.3; -// 캔버스의 스프라이트는 정적이다: 평소엔 idle 상태, 자세 팝업이 열려 있으면 -// 그 자세를 그린다 (편집창 전용 프리뷰). 키 눌림 라이브 반응은 오버레이 창 몫 +// 캔버스의 스프라이트는 정적이다: 평소엔 기본 자세, 자세 팝업이 열려 있으면 +// 그 자세를 그린다 (편집창 전용 프리뷰). 키 눌림 라이브 반응은 오버레이 창 몫. +// 선택·기준점·자세 핸들은 그리드 오버레이 층(SpriteCanvasHandles)이 그린다 const SpriteItem = ({ index, elementId, @@ -68,30 +57,16 @@ const SpriteItem = ({ className, } = position; - // 편집 중 프리뷰 - 자세 팝업이면 그 자세를 그린다. 유효 자세는 composed poses가 - // 최신(스크럽 프리뷰 병합)이라 우선하고, 무효 draft(preferFallback)나 canonical에 - // 없는 신규 자세는 발행된 스냅샷으로 그린다. transform과 이미지는 같은 자세에서 - // 함께 파생 (혼합 상태 방지). 기준점 편집 중이면 축 마커만 얹는다 - const editPreview = useSpriteEditPreview(elementId); - const posePreview = editPreview?.kind === 'pose' ? editPreview : null; + // 편집 중 프리뷰 - 자세 팝업이면 그 자세를 그린다. 드래그·스크럽 중이거나 무효 + // draft면 발행된 스냅샷을 우선하고, 평소에는 canonical 자세를 따른다 + // transform과 이미지는 같은 자세에서 함께 파생해 혼합 상태를 막는다 + const posePreview = useSpriteEditPreview(elementId); const previewPose = posePreview ? posePreview.preferFallback ? posePreview.fallbackPose : position.poses.find((pose) => pose.poseId === posePreview.poseId) ?? posePreview.fallbackPose : null; - // 기준점은 회전·배율의 축이자 축 배치의 고정점이라 선택 중에는 늘 보이고, - // 기준점 편집 중에는 강조한다. 자세 편집 중에는 기즈모가 자세 이동값이 적용된 - // 축을 그리므로 여기 표식은 숨겨 축이 둘로 보이지 않게 한다 - const pivotEditing = editPreview?.kind === 'pivot'; - const showPivotMarker = - pivotEditing || (isSelected && editPreview?.kind !== 'pose'); - // 표식 위치는 화면에서 실제로 회전이 일어나는 점 - 축에 기본 자세의 이동값이 더해진다 - const pivotAxisPx = anchorPx(position.imageRect, position.pivot); - const pivotMarkerPx = { - x: pivotAxisPx.x + position.idleTransform.x, - y: pivotAxisPx.y + position.idleTransform.y, - }; // 유실 이미지를 그대로 두면 캔버스에 깨진 아이콘이 박히고, 송출 화면(오버레이)은 // 폴백을 그려 미리보기가 결과와 어긋난다. 폴백 규칙은 오버레이와 같다. @@ -106,7 +81,7 @@ const SpriteItem = ({ let imageSrc = resolveImageSource( resolvePoseImage(previewPose?.imageOverride, position.baseImage), ); - // 이미지·원본 크기·축은 한 벌로 움직인다 - 폴백도 기본 이미지 배치로 함께 간다 + // 이미지·원본 크기는 한 벌로 움직인다 - 폴백도 기본 이미지 배치로 함께 간다 let visual = previewPose ? spritePoseVisual(position, previewPose) : spriteIdleVisual(position); @@ -115,6 +90,18 @@ const SpriteItem = ({ visual = spriteIdleVisual(position); } const placement = placeSpriteVisual(position, visual); + const transform = previewPose + ? previewPose.transform + : position.idleTransform; + + // 자세 편집 중에는 기본 자세를 반투명 고스트로 뒤에 남겨 "어디서 얼마나 + // 움직였는지"가 보인다. 기본 자세와 같은 그림이면 겹쳐 그릴 이유가 없다 + const idlePlacement = placeSpriteVisual(position, spriteIdleVisual(position)); + const showIdleGhost = + previewPose !== null && + fallbackSrc !== null && + (imageSrc !== fallbackSrc || + !isSameSpriteTransform(transform, position.idleTransform)); const { isSelectionMode, @@ -154,18 +141,16 @@ const SpriteItem = ({ if (position.hidden) return null; - const transform = `translate(calc(${draggable.dx}px + var(--key-offset-x, 0px)), calc(${draggable.dy}px + var(--key-offset-y, 0px)))`; + const rootTransform = `translate(calc(${draggable.dx}px + var(--key-offset-x, 0px)), calc(${draggable.dy}px + var(--key-offset-y, 0px)))`; return (
+ {showIdleGhost ? ( + + ) : null} {imageSrc ? ( )} - {isSelected ? null : ( - // 활동 영역 가이드 - 레이아웃에 참여하지 않는 별도 층이라 안쪽 이미지 - // 좌표를 밀지 않는다 (오버레이와 같은 원점). 이미지 위에 그려 가려지지 않는다 -
- )} - {showPivotMarker ? ( - // 기준점 마커 - 회전·배율 축의 위치를 실시간으로 보여준다 (기준점 편집 동안) -
- -
- ) : null}
); diff --git a/src/renderer/components/main/common/Checkbox.tsx b/src/renderer/components/main/common/Checkbox.tsx index 7fe4ee61a..1a8e21c15 100644 --- a/src/renderer/components/main/common/Checkbox.tsx +++ b/src/renderer/components/main/common/Checkbox.tsx @@ -9,16 +9,18 @@ import { interface CheckboxProps { checked: boolean; onChange: () => void; + ariaLabel?: string; commitStrategy?: BooleanCommitStrategy; /** 드래그로 노브가 넘어간 쪽까지 반영한 표시 값. 확정 전 값이라 표시 전용으로만 쓴다 */ onDisplayChange?: (value: boolean) => void; } -// 토글 스위치 — 이름은 기존 사용처 호환을 위해 유지. +// 토글 스위치 - 이름은 기존 사용처 호환을 위해 유지 // 직접 클릭에서 온 변경만 노브 슬라이드, 외부 상태 변경은 즉시 이동 const Checkbox = ({ checked, onChange, + ariaLabel, commitStrategy = 'sync', onDisplayChange, }: CheckboxProps) => { @@ -62,6 +64,7 @@ const Checkbox = ({ ref={trackRef} role="switch" aria-checked={visualChecked} + aria-label={ariaLabel} className={`dmn-toggle-track relative w-[28px] h-[16px] rounded-full cursor-pointer transition-colors duration-base ease-out-expo ${ displayChecked ? 'bg-accent' : 'bg-line-strong hover:bg-white/[0.19]' }`} diff --git a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx index 1d84fd3cc..e8299424b 100644 --- a/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx +++ b/src/renderer/components/overlay/counters/OverlaySpriteItem.tsx @@ -133,18 +133,18 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ // 실패한 src는 baseImage로 폴백, 그것도 실패면 렌더 제외. // 배치도 이미지와 한 벌 - 폴백이면 기본 이미지 배치로 함께 돌아간다 + const idlePlacement = placeSpriteVisual(position, spriteIdleVisual(position)); let heldImageSrc = heldTarget ? resolveImageSource(heldTarget.imageSrc) : null; let heldPlacement = heldTarget ? placeSpriteVisual(position, heldTarget.visual) - : null; + : idlePlacement; if (heldImageSrc && failedImageSrcs.has(heldImageSrc)) { heldImageSrc = baseImageSrc; - heldPlacement = placeSpriteVisual(position, spriteIdleVisual(position)); + heldPlacement = idlePlacement; } if (heldImageSrc && failedImageSrcs.has(heldImageSrc)) heldImageSrc = null; - const idlePlacement = placeSpriteVisual(position, spriteIdleVisual(position)); // 첫 자세 전환에서 cold decode가 겹치지 않도록 base와 모든 override를 선행 디코드 const warmupSrcs = useMemo(() => { @@ -258,18 +258,19 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ () => stableStringify({ baseImage: position.baseImage, - imageFit: position.imageFit, - imageRect: position.imageRect, + width: position.width, + height: position.height, pivot: position.pivot, - imagePlacement: position.imagePlacement, referenceNaturalSize: position.referenceNaturalSize, idleTransform: position.idleTransform, useInlineStyles: position.useInlineStyles, + // 자세 기준점·이동값도 재생이 DOM에 직접 쓰는 축·transform을 바꾼다 poses: position.poses.map((pose) => [ pose.poseId, pose.imageOverride, - pose.imagePivot, pose.imageOverrideMetrics, + pose.pivot, + pose.transform, ]), }), [position], @@ -503,7 +504,7 @@ const OverlaySpriteItem = React.memo(function OverlaySpriteItem({ position, heldTarget.transform, transitionCss, - heldPlacement ?? undefined, + heldPlacement, ), willChange: 'transform', }} diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.ts b/src/renderer/hooks/Grid/useGridCanvasActions.ts index 04ad29fb9..a70797f70 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.ts +++ b/src/renderer/hooks/Grid/useGridCanvasActions.ts @@ -31,7 +31,6 @@ import type { KnobItemPosition } from '@src/types/key/knobs'; import { CENTER_SPRITE_ANCHOR, DEFAULT_SPRITE_ACTIVATION, - NEW_SPRITE_IMAGE_PLACEMENT, DEFAULT_SPRITE_PRESS_DURATION_MS, DEFAULT_SPRITE_SIZE, DEFAULT_SPRITE_TRANSITION_EASING, @@ -512,15 +511,6 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { className: null, useInlineStyles: null, baseImage: null, - // 축 배치는 상자가 비트맵 자체라 상자를 늘리면 그림도 늘어난다(레이어 스케일). - // 맞춤이면 비균등 리사이즈마다 여백이 생긴다 - imageFit: 'fill', - imageRect: { - x: 0, - y: 0, - width: DEFAULT_SPRITE_SIZE, - height: DEFAULT_SPRITE_SIZE, - }, pivot: { ...CENTER_SPRITE_ANCHOR }, idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, poses: [], @@ -528,9 +518,7 @@ export function useGridCanvasActions(selectedKeyType: string): CanvasActions { pressDurationMs: DEFAULT_SPRITE_PRESS_DURATION_MS, transitionMs: DEFAULT_SPRITE_TRANSITION_MS, transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, - // 새 스프라이트는 축 배치 - 이미지 크기·비율이 달라도 축이 유지된다. // 기준 크기는 첫 이미지를 고를 때 패널이 채운다 - imagePlacement: NEW_SPRITE_IMAGE_PLACEMENT, referenceNaturalSize: null, }, ); diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index b44b3b6f7..4ae5b3a41 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -554,9 +554,10 @@ "knobReverse": "Reverse Direction", "spriteBaseImage": "Base Image", "spriteImageSelect": "Select", - "spriteImageRect": "Position", - "spriteImageSize": "Size", "spritePivot": "Pivot", + "spritePivotHint": "Drag: move the pivot · Ctrl/⌘: no snapping", + "spriteFollowBasePivot": "Follow Base Pivot", + "spriteStatePivot": "State Pivot", "spriteRotation": "Rotation", "spriteScale": "Scale", "spritePose": "State", @@ -574,16 +575,6 @@ "spriteActivationHold": "While held", "spriteActivationPress": "One-shot", "spritePressDuration": "Play duration", - "spriteContactPoint": "Fingertip", - "spritePinLock": "Pin lock", - "spriteImagePlacement": "Placement", - "spritePlacementBox": "Fit to box", - "spritePlacementPivot": "Anchor based", - "spriteImagePivot": "Anchor", - "spriteInheritPivot": "Use base anchor", - "spriteStretch": "Stretch", - "spriteContactKnobHint": "Drag: aim the fingertip at a key · Alt-drag: move the pin", - "spriteContactDegenerate": "The pin overlaps the pivot. Alt-drag to move the pin", "graphSpeed": "Graph Speed", "graphColor": "Graph Color", "graphAnimation": "Graph Motion", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index b5f50765d..ebbbf6f77 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -554,9 +554,10 @@ "knobReverse": "방향 반전", "spriteBaseImage": "기본 이미지", "spriteImageSelect": "선택", - "spriteImageRect": "위치", - "spriteImageSize": "크기", "spritePivot": "기준점", + "spritePivotHint": "드래그: 기준점 이동 · Ctrl/⌘: 스냅 해제", + "spriteFollowBasePivot": "기본 기준점 따라가기", + "spriteStatePivot": "상태 기준점", "spriteRotation": "회전", "spriteScale": "배율", "spritePose": "상태", @@ -574,16 +575,6 @@ "spriteActivationHold": "누름 유지", "spriteActivationPress": "단발 재생", "spritePressDuration": "재생 시간", - "spriteContactPoint": "손끝", - "spritePinLock": "핀 고정", - "spriteImagePlacement": "배치", - "spritePlacementBox": "상자 맞춤", - "spritePlacementPivot": "축 기준", - "spriteImagePivot": "축", - "spriteInheritPivot": "기본 축 사용", - "spriteStretch": "뻗기", - "spriteContactKnobHint": "드래그: 손끝을 키에 조준 · Alt 드래그: 핀 이동", - "spriteContactDegenerate": "핀이 기준점과 겹쳐 있습니다. Alt 드래그로 핀을 옮기세요", "graphSpeed": "그래프 속도", "graphColor": "그래프 색상", "graphAnimation": "그래프 모션", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index cb0a007fa..f65692f61 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -559,9 +559,10 @@ "knobReverse": "Обратное направление", "spriteBaseImage": "Базовая картинка", "spriteImageSelect": "Выбрать", - "spriteImageRect": "Позиция", - "spriteImageSize": "Размер", "spritePivot": "Точка опоры", + "spritePivotHint": "Перетаскивание: переместить точку опоры · Ctrl/⌘: без привязки", + "spriteFollowBasePivot": "Следовать базовой точке", + "spriteStatePivot": "Точка состояния", "spriteRotation": "Поворот", "spriteScale": "Масштаб", "spritePose": "Состояние", @@ -579,16 +580,6 @@ "spriteActivationHold": "Удержание", "spriteActivationPress": "Один раз", "spritePressDuration": "Длительность", - "spriteContactPoint": "Кончик", - "spritePinLock": "Закрепить кончик", - "spriteImagePlacement": "Размещение", - "spritePlacementBox": "По рамке", - "spritePlacementPivot": "По оси", - "spriteImagePivot": "Ось", - "spriteInheritPivot": "Использовать основную ось", - "spriteStretch": "Растягивание", - "spriteContactKnobHint": "Перетаскивание: навести кончик на клавишу · Alt: переместить точку", - "spriteContactDegenerate": "Точка совпадает с осью вращения. Переместите её перетаскиванием с Alt", "customImage": "Своя картинка", "configure": "Настроить", "className": "Класс", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 7d76367ba..55da78910 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -559,9 +559,10 @@ "knobReverse": "反轉方向", "spriteBaseImage": "基礎圖像", "spriteImageSelect": "選擇", - "spriteImageRect": "位置", - "spriteImageSize": "大小", "spritePivot": "基準點", + "spritePivotHint": "拖曳:移動基準點 · Ctrl/⌘:取消吸附", + "spriteFollowBasePivot": "跟隨基礎基準點", + "spriteStatePivot": "狀態基準點", "spriteRotation": "旋轉", "spriteScale": "縮放", "spritePose": "狀態", @@ -579,16 +580,6 @@ "spriteActivationHold": "按住維持", "spriteActivationPress": "單次播放", "spritePressDuration": "播放時間", - "spriteContactPoint": "指尖", - "spritePinLock": "固定指尖", - "spriteImagePlacement": "放置", - "spritePlacementBox": "適配框", - "spritePlacementPivot": "以軸為準", - "spriteImagePivot": "軸", - "spriteInheritPivot": "使用基礎軸", - "spriteStretch": "伸展", - "spriteContactKnobHint": "拖曳:將指尖對準按鍵 · Alt 拖曳:移動指尖點", - "spriteContactDegenerate": "指尖點與基準點重疊,請以 Alt 拖曳移動", "customImage": "自定義圖像", "configure": "配置", "className": "類名", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 5deb189fb..7dee90176 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -559,9 +559,10 @@ "knobReverse": "反转方向", "spriteBaseImage": "基础图像", "spriteImageSelect": "选择", - "spriteImageRect": "位置", - "spriteImageSize": "大小", "spritePivot": "基准点", + "spritePivotHint": "拖动:移动基准点 · Ctrl/⌘:取消吸附", + "spriteFollowBasePivot": "跟随基础基准点", + "spriteStatePivot": "状态基准点", "spriteRotation": "旋转", "spriteScale": "缩放", "spritePose": "状态", @@ -579,16 +580,6 @@ "spriteActivationHold": "按住保持", "spriteActivationPress": "单次播放", "spritePressDuration": "播放时间", - "spriteContactPoint": "指尖", - "spritePinLock": "固定指尖", - "spriteImagePlacement": "放置", - "spritePlacementBox": "适配框", - "spritePlacementPivot": "以轴为准", - "spriteImagePivot": "轴", - "spriteInheritPivot": "使用基础轴", - "spriteStretch": "伸展", - "spriteContactKnobHint": "拖动:将指尖对准按键 · Alt 拖动:移动指尖点", - "spriteContactDegenerate": "指尖点与基准点重叠,请用 Alt 拖动移动", "customImage": "自定义图像", "configure": "配置", "className": "类名", diff --git a/src/renderer/stores/grid/useSpriteEditPreviewStore.ts b/src/renderer/stores/grid/useSpriteEditPreviewStore.ts index f5d0d3067..f5a0d8528 100644 --- a/src/renderer/stores/grid/useSpriteEditPreviewStore.ts +++ b/src/renderer/stores/grid/useSpriteEditPreviewStore.ts @@ -2,34 +2,26 @@ import { create } from 'zustand'; import type { SpritePose } from '@src/types/key/sprites'; /** - * 스프라이트 편집 캔버스 프리뷰 - 패널이 편집 중인 것을 캔버스가 보조 표시하게 - * 공유한다. 자세 팝업은 그 자세의 렌더, 기준점 편집은 축 마커. - * 편집창(그리드) 전용이고 오버레이는 커밋만 반영한다. + * 스프라이트 편집 캔버스 프리뷰 - 자세 팝업이 열려 있는 동안 캔버스가 그 자세를 + * 그리도록 패널이 공유한다. 편집창(그리드) 전용이고 오버레이는 커밋만 반영한다. * 발행자는 속성 패널 하나뿐이라 스택 없이 단일 값으로 충분 */ -export type SpriteEditPreview = - | { - kind: 'pose'; - positionId: string; - poseId: string; - /** composed poses에 없거나(신규 draft) 커밋 불가 상태일 때 그릴 스냅샷 */ - fallbackPose: SpritePose; - /** 무효 draft 편집 중 - composed의 옛 canonical 값 대신 스냅샷을 우선한다 */ - preferFallback: boolean; - } - | { kind: 'pivot'; positionId: string }; +export interface SpriteEditPreview { + kind: 'pose'; + positionId: string; + poseId: string; + /** composed poses에 없거나(신규 draft) 커밋 불가 상태일 때 그릴 스냅샷 */ + fallbackPose: SpritePose; + /** 무효 draft 편집 중 - composed의 옛 canonical 값 대신 스냅샷을 우선한다 */ + preferFallback: boolean; +} -const previewEquals = (a: SpriteEditPreview, b: SpriteEditPreview): boolean => { - if (a.kind !== b.kind || a.positionId !== b.positionId) return false; - if (a.kind === 'pivot') return true; - return ( - b.kind === 'pose' && - a.poseId === b.poseId && - a.fallbackPose === b.fallbackPose && - a.preferFallback === b.preferFallback - ); -}; +const previewEquals = (a: SpriteEditPreview, b: SpriteEditPreview): boolean => + a.positionId === b.positionId && + a.poseId === b.poseId && + a.fallbackPose === b.fallbackPose && + a.preferFallback === b.preferFallback; interface SpriteEditPreviewStore { preview: SpriteEditPreview | null; diff --git a/src/renderer/stores/grid/useSpritePoseHandleStore.ts b/src/renderer/stores/grid/useSpritePoseHandleStore.ts index bca6daae1..e8f836606 100644 --- a/src/renderer/stores/grid/useSpritePoseHandleStore.ts +++ b/src/renderer/stores/grid/useSpritePoseHandleStore.ts @@ -16,12 +16,18 @@ export interface SpritePoseHandleSession { /** 요소 상자와 기준점 - 회전·배율 축 P의 근거 */ width: number; height: number; + /** 요소의 공통 논리 원점 */ pivot: SpriteAnchor; + /** 상태 이미지 내부 기준점. 연결 상태에서는 pivot과 같다 */ + imagePivot: SpriteAnchor; + followsBasePivot: boolean; /** 자세 이미지의 배치 (요소 로컬 px) - 프레임 폴리곤의 원본 */ placement: SpritePlacement; transform: SpriteTransform; preview: (transform: SpriteTransform) => void; commit: (transform: SpriteTransform) => void; + previewPivot: (pivot: SpriteAnchor, transform: SpriteTransform) => void; + commitPivot: (pivot: SpriteAnchor, transform: SpriteTransform) => void; cancel: () => void; } @@ -40,6 +46,27 @@ interface SpritePoseHandleState { const ownerKey = (session: SpritePoseHandleSession): string => `${session.positionId}\n${session.poseId}`; +const sameSessionGeometry = ( + current: SpritePoseHandleSession, + next: SpritePoseHandleSession, +): boolean => + current.origin.dx === next.origin.dx && + current.origin.dy === next.origin.dy && + current.width === next.width && + current.height === next.height && + current.pivot.x === next.pivot.x && + current.pivot.y === next.pivot.y && + current.imagePivot.x === next.imagePivot.x && + current.imagePivot.y === next.imagePivot.y && + current.placement.rect.x === next.placement.rect.x && + current.placement.rect.y === next.placement.rect.y && + current.placement.rect.width === next.placement.rect.width && + current.placement.rect.height === next.placement.rect.height && + current.transform.x === next.transform.x && + current.transform.y === next.transform.y && + current.transform.rotation === next.transform.rotation && + current.transform.scale === next.transform.scale; + export const useSpritePoseHandleStore = create( (set) => ({ session: null, @@ -57,7 +84,11 @@ export const useSpritePoseHandleStore = create( : {}; } const key = ownerKey(session); - return key !== state.lastOwnerKey + const geometryChanged = + state.session !== null && + key === state.lastOwnerKey && + !sameSessionGeometry(state.session, session); + return key !== state.lastOwnerKey || geometryChanged ? { session, lastOwnerKey: key, diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index ca831776c..afb5aaed4 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -258,7 +258,7 @@ input[type='number']::-webkit-outer-spin-button { * 사용자 CSS는 공개 --sprite-* 변수나 일반 선언으로 언제든 덮어쓴다 */ :where([data-sprite-element] > img) { - object-fit: var(--sprite-fit, var(--dmn-sprite-fit-default, contain)); + object-fit: fill; transform: var(--sprite-transform, var(--dmn-sprite-transform-default, none)); transition: var( --sprite-transition, diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css index 405354faa..cbe6c185d 100644 --- a/src/renderer/styles/tokens.css +++ b/src/renderer/styles/tokens.css @@ -125,7 +125,7 @@ 시맨틱 danger를 재사용하지 않는다. 오류가 아니라 위치 스냅 표시 */ --ui-guide-align: #ff6b6b; --ui-guide-align-glow: rgba(255, 107, 107, 0.5); - /* 스프라이트 활동 영역 점선 - 캔버스 아이템과 복제 고스트가 함께 쓴다 */ + /* 스프라이트 복제 고스트 점선 - 놓일 자리의 상자 윤곽 */ --ui-guide-activity: rgba(237, 238, 242, 0.4); /* 캔버스 선택 크롬 — 셀렉션은 의미 지점이므로 브랜드 바이올렛 */ From bdd93885505905484098723ebd959cd82220b00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Thu, 3 Sep 2026 21:30:04 +0900 Subject: [PATCH 093/125] =?UTF-8?q?test:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=EC=A4=80=EC=A0=90=C2=B7=EC=9E=90?= =?UTF-8?q?=EC=84=B8=20=ED=8E=B8=EC=A7=91=20=ED=9A=8C=EA=B7=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/previewOverlaySession.test.ts | 55 ++ .../renderedElementContract.test.tsx | 5 - .../api/modules/editorAdapters.test.ts | 73 +- src/renderer/api/modules/editorApi.test.ts | 5 - ...PropertiesPanel.detachedContracts.test.tsx | 6 +- .../single/SingleSpritePanel.test.tsx | 539 +++++++---- .../single/SpriteImagePreviewCard.test.tsx | 1 - .../single/spriteCompilerGuard.test.ts | 4 +- .../main/Grid/handles/ResizeHandles.test.tsx | 21 + .../SpriteCanvasHandles.integration.test.tsx | 465 +++++++++ ...asHandles.poseSession.integration.test.tsx | 903 ++++++++++++++++++ .../Grid/handles/SpriteCanvasHandles.test.tsx | 665 +++++++++++++ .../Grid/handles/SpritePoseGizmo.test.tsx | 323 ------- .../Grid/layers/SpriteDuplicateGhost.test.tsx | 11 +- .../Grid/layers/SpriteItem.render.test.tsx | 191 ++-- .../counters/overlaySpriteItem.test.tsx | 37 +- .../editor/runtime/editorCoordinator.test.ts | 7 - .../editor/runtime/elementOps.test.ts | 18 - .../editor/runtime/mixedBatchGeometry.test.ts | 4 +- .../editor/runtime/mixedElementGroups.test.ts | 3 - .../editor/runtime/previewOverlay.test.ts | 76 ++ .../spritePatchOptimisticIntegration.test.ts | 5 - .../hooks/Grid/useGridCanvasActions.test.tsx | 6 - .../hooks/Grid/useGridResize.test.tsx | 9 +- .../hooks/Grid/useGridSelection.test.tsx | 9 - .../hooks/shared/useLayoutComputation.test.ts | 11 - .../grid/useSpriteEditPreviewStore.test.ts | 22 +- .../utils/sprite/contactSolver.test.ts | 165 ---- .../utils/sprite/poseResolver.test.ts | 20 +- .../utils/sprite/resizeProjection.test.ts | 19 +- src/renderer/utils/sprite/spriteFixtures.ts | 12 - .../utils/sprite/spritePlacement.test.ts | 465 +++++++-- .../sprite/spritePlacementSwitch.test.ts | 112 --- .../utils/sprite/spritePoseIntent.test.ts | 90 ++ src/renderer/utils/sprite/spriteReach.test.ts | 21 +- .../utils/sprite/spriteWireShape.test.ts | 2 +- .../overlay/App.spritePreview.test.tsx | 3 - src/types/key/sprites.test.ts | 30 +- tests/fixtures/editor-ops-parity.json | 22 +- tests/fixtures/sprite-constraint-parity.json | 13 +- .../fixtures/sprite-resize-scale-parity.json | 63 +- tests/sprite-constraint-parity.test.ts | 72 +- tests/sprite-resize-scale-parity.test.ts | 21 +- 43 files changed, 3365 insertions(+), 1239 deletions(-) create mode 100644 src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx create mode 100644 src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx create mode 100644 src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx delete mode 100644 src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx create mode 100644 src/renderer/editor/runtime/previewOverlay.test.ts delete mode 100644 src/renderer/utils/sprite/contactSolver.test.ts delete mode 100644 src/renderer/utils/sprite/spritePlacementSwitch.test.ts create mode 100644 src/renderer/utils/sprite/spritePoseIntent.test.ts diff --git a/src/renderer/__tests__/previewOverlaySession.test.ts b/src/renderer/__tests__/previewOverlaySession.test.ts index 70bae9e83..a4bb45ff6 100644 --- a/src/renderer/__tests__/previewOverlaySession.test.ts +++ b/src/renderer/__tests__/previewOverlaySession.test.ts @@ -324,6 +324,38 @@ describe('editGestureController', () => { ); }); + it('새 게스처는 남은 로컬 프리뷰를 교체하고 브로커에도 종료를 알린다', () => { + const staleSessionId = '11111111-1111-4111-8111-111111111111'; + previewOverlay.applyLocalPatch(staleSessionId, '4key', [0], { + backgroundColor: '#111111', + }); + + editGestureController.preview('4key', [ + { id: KEY_ID_A, patch: { backgroundColor: '#abcdef' } }, + ]); + + expect(previewApi.cancel).toHaveBeenCalledWith(staleSessionId); + expect(useKeyStore.getState().positions['4key'][0].backgroundColor).toBe( + '#abcdef', + ); + }); + + it('활성 게스처가 없어도 cancel은 남은 로컬 프리뷰를 회수한다', () => { + const staleSessionId = '11111111-1111-4111-8111-111111111111'; + const canonicalColor = + useKeyStore.getState().positions['4key'][0].backgroundColor; + previewOverlay.applyLocalPatch(staleSessionId, '4key', [0], { + backgroundColor: '#111111', + }); + + editGestureController.cancel(); + + expect(previewApi.cancel).toHaveBeenCalledWith(staleSessionId); + expect(useKeyStore.getState().positions['4key'][0].backgroundColor).toBe( + canonicalColor, + ); + }); + it('preview는 coalescing 후 채널로 발행됨', async () => { editGestureController.preview('4key', [ { id: KEY_ID_A, patch: { backgroundColor: '#111111' } }, @@ -447,6 +479,29 @@ describe('editGestureController', () => { expect(useKeyStore.getState().positions['4key'][0].width).toBe(100); }); + it('새 조작이 교체한 정산 대기 세션은 늦게 실패해도 되살아나지 않는다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + let rejectFirst!: (reason: Error) => void; + const firstCommit = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + + editGestureController.preview('4key', [ + { id: KEY_ID_A, patch: { width: 100 } }, + ]); + editGestureController.settleCommit(firstCommit); + + editGestureController.preview('4key', [ + { id: KEY_ID_A, patch: { width: 120 } }, + ]); + editGestureController.cancel(); + rejectFirst(new Error('late io')); + await flushPromises(); + + expect(editGestureController.hasActiveGesture()).toBe(false); + expect(useKeyStore.getState().positions['4key'][0].width).toBe(60); + }); + it('settleCommit 실패 뒤 편집 대상이 갈렸으면 세션을 되살리지 않는다', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); useGridSelectionStore.setState({ diff --git a/src/renderer/__tests__/renderedElementContract.test.tsx b/src/renderer/__tests__/renderedElementContract.test.tsx index ccd90c95d..7cf7929d0 100644 --- a/src/renderer/__tests__/renderedElementContract.test.tsx +++ b/src/renderer/__tests__/renderedElementContract.test.tsx @@ -359,14 +359,10 @@ describe('렌더 DOM 계약', () => { className: null, useInlineStyles: null, baseImage: 'data:image/png;base64,base', - imageFit: null, - imageRect: { x: 0, y: 0, width: 60, height: 60 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-contract', triggers: ['el-contract'], @@ -376,7 +372,6 @@ describe('렌더 DOM 계약', () => { ], transitionMs: 90, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', - imagePlacement: 'box', referenceNaturalSize: null, }} keyCanonicalMap={new Map()} diff --git a/src/renderer/api/modules/editorAdapters.test.ts b/src/renderer/api/modules/editorAdapters.test.ts index 258b654b9..d0ec40b16 100644 --- a/src/renderer/api/modules/editorAdapters.test.ts +++ b/src/renderer/api/modules/editorAdapters.test.ts @@ -52,14 +52,11 @@ const spriteFixture = (id: string): CanonicalReactiveSpritePosition => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 100, height: 100 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [], transitionMs: 90, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', - imagePlacement: 'box', referenceNaturalSize: null, }); @@ -190,6 +187,76 @@ describe('editor API compatibility adapters', () => { expect(generated[0]).toBeNull(); }); + it('sprite patchPosition은 대기 뒤 최신 대상에서 의도 patch를 다시 계산한다', async () => { + const sprite = { + ...spriteFixture('cccccccc-cccc-4ccc-8ccc-cccccccccccc'), + poses: [ + { + poseId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + triggers: ['ffffffff-ffff-4fff-8fff-ffffffffffff'], + transform: { x: 42, y: 7, rotation: 0, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + pivot: { x: 0.4, y: 0.6 }, + }, + ], + }; + const base = { + ...structuredClone(document), + spritePositions: { '4key': [sprite] }, + } as CanonicalEditorDocumentV1; + let generated: EditorPatchV1 | null = null; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated = generate(base) as EditorPatchV1 | null; + return structuredClone(document); + }, + ); + + const stalePoses = structuredClone(sprite.poses); + stalePoses[0].transform.x = -100; + await spriteItemsApi.patchPosition( + '4key', + sprite.id, + { poses: stalePoses }, + undefined, + (current) => ({ + pivot: { x: 0.25, y: 0.75 }, + poses: current.poses, + }), + ); + + const patched = generated?.spritePositions?.['4key']?.[0]; + expect(patched?.pivot).toEqual({ x: 0.25, y: 0.75 }); + expect(patched?.poses[0].transform.x).toBe(42); + }); + + it('sprite patchPosition은 최신 상태에서 의도를 적용할 수 없으면 건너뛴다', async () => { + const sprite = spriteFixture('cccccccc-cccc-4ccc-8ccc-cccccccccccc'); + const base = { + ...structuredClone(document), + spritePositions: { '4key': [sprite] }, + } as CanonicalEditorDocumentV1; + let generated: EditorPatchV1 | null = null; + commitGeneratedPatch.mockImplementation( + async (generate: (base: CanonicalEditorDocumentV1) => unknown) => { + generated = generate(base) as EditorPatchV1 | null; + return structuredClone(document); + }, + ); + + await expect( + spriteItemsApi.patchPosition( + '4key', + sprite.id, + { hidden: true }, + undefined, + () => null, + ), + ).resolves.toBe('skipped'); + expect(generated).toBeNull(); + }); + it('sprite patchPosition은 gestureId를 editor_commit 메타로 실어 보낸다', async () => { // 수동 mock은 generate를 실행하지 않아 typed 결과는 targetMissing 유지 await expect( diff --git a/src/renderer/api/modules/editorApi.test.ts b/src/renderer/api/modules/editorApi.test.ts index c8de01a4c..4664926a5 100644 --- a/src/renderer/api/modules/editorApi.test.ts +++ b/src/renderer/api/modules/editorApi.test.ts @@ -1357,14 +1357,10 @@ describe('canonical 검증의 스프라이트 poseId 네임스페이스', () => className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 200, height: 200 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: POSE_ID, triggers: [], @@ -1374,7 +1370,6 @@ describe('canonical 검증의 스프라이트 poseId 네임스페이스', () => ], transitionMs: 90, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', - imagePlacement: 'box', referenceNaturalSize: null, ...overrides, }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx index ca1ad8f1c..1e42b2013 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.detachedContracts.test.tsx @@ -960,7 +960,7 @@ describe('PropertiesPanel canonical native contract', () => { // 크기 입력은 resize가 실제로 조절하는 집합을 그대로 읽어야 한다. // 스타일 집합에는 스프라이트가 없어서 그걸 쓰면 키 값이 대표로 뜨고, - // 그 값을 확정하면 스프라이트 imageRect와 모든 자세 오프셋까지 배율이 먹는다 + // 그 값을 확정하면 스프라이트의 모든 자세 오프셋까지 배율이 먹는다 it('키와 스프라이트를 함께 고르면 크기 getter가 Mixed를 낸다', () => { const key = { ...createDefaultKeyPosition(), id: MIXED_KEY_ID }; useKeyStore.setState({ @@ -1007,7 +1007,7 @@ describe('PropertiesPanel canonical native contract', () => { }); }); - // 커밋은 resizeSprite로 imageRect와 자세까지 스케일한다. 미리보기가 원시 + // 커밋은 resizeSprite로 자세까지 스케일한다. 미리보기가 원시 // bounds만 얹으면 드래그 중엔 활동 영역만 줄다가 놓는 순간 튄다 it('스프라이트 크기 미리보기는 이미지와 자세까지 커밋과 같게 투영한다', () => { const key = { ...createDefaultKeyPosition(), id: MIXED_KEY_ID }; @@ -1025,7 +1025,6 @@ describe('PropertiesPanel canonical native contract', () => { groupId: null, width: 200, height: 200, - imageRect: { x: 0, y: 0, width: 200, height: 200 }, poses: [ makeSpritePose({ poseId: 'pose-1', @@ -1067,7 +1066,6 @@ describe('PropertiesPanel canonical native contract', () => { )[0].patch; // 200 -> 100 이므로 배율 0.5가 콘텐츠에도 그대로 걸린다 expect(patch.width).toBe(100); - expect(patch.imageRect).toMatchObject({ width: 100 }); expect( (patch.poses as Array<{ transform: { x: number } }>)[0].transform.x, ).toBe(-30); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index d6de92505..c10bd319b 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -7,9 +7,9 @@ import { useSpriteStore } from '@stores/data/useSpriteStore'; import { useKeyStore } from '@stores/data/useKeyStore'; import { useSpriteEditPreviewStore } from '@stores/grid/useSpriteEditPreviewStore'; import { - useSpritePoseGizmoStore, - type SpritePoseGizmoSession, -} from '@stores/grid/useSpritePoseGizmoStore'; + useSpritePoseHandleStore, + type SpritePoseHandleSession, +} from '@stores/grid/useSpritePoseHandleStore'; import { makeSpritePose } from '@utils/sprite/spriteFixtures'; import { projectSpriteResize } from '@utils/sprite/resizeProjection'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; @@ -23,8 +23,13 @@ class ResizeObserverStub { const mocks = vi.hoisted(() => ({ patchPosition: vi.fn( - (_mode: string, _id: string, _patch: unknown, _gestureId?: string) => - Promise.resolve(undefined), + ( + _mode: string, + _id: string, + _patch: unknown, + _gestureId?: string, + _generatePatch?: unknown, + ) => Promise.resolve(undefined), ), preview: vi.fn(), gestureCancel: vi.fn(), @@ -37,6 +42,16 @@ const mocks = vi.hoisted(() => ({ ), canDecodeImage: vi.fn(() => Promise.resolve(true)), probeImageSize: vi.fn(() => Promise.resolve({ width: 64, height: 32 })), + commitBounds: vi.fn( + (_type: string, _id: string, _bounds: unknown, _gestureId?: string) => + Promise.resolve(true), + ), +})); + +// 요소 상자(위치·크기)는 resizeSprite op 경로 - 커밋 인자만 검증한다 +vi.mock('@src/renderer/editor/runtime/elementOps', async (importOriginal) => ({ + ...(await importOriginal>()), + commitSingleElementBoundsById: mocks.commitBounds, })); vi.mock('@api/modules/itemsApi', () => ({ @@ -126,14 +141,11 @@ const spritePosition = ( className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 100, height: 100 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [], transitionMs: 90, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', - imagePlacement: 'box', referenceNaturalSize: null, ...overrides, }); @@ -328,20 +340,21 @@ describe('SingleSpritePanel 자세 편집', () => { name: 'propertiesPanel.spritePose 1', triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + pivot: null, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }, ]); + const generatePatch = mocks.patchPosition.mock.calls[0][4] as ( + current: CanonicalReactiveSpritePosition, + ) => Partial; + expect(generatePatch(position).poses).toEqual(patch.poses); }); it('중복 트리거 집합은 커밋을 막고 경고를 표시한다', async () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -377,8 +390,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [], @@ -419,8 +430,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -436,7 +445,7 @@ describe('SingleSpritePanel 자세 편집', () => { expect(posePopup()!.getAttribute('aria-label')).toBe( 'propertiesPanel.spritePose 1', ); - expect(posePopup()!.dataset.fallbackHeight).toBe('316'); + expect(posePopup()!.dataset.fallbackHeight).toBe('240'); // 같은 행 재클릭 - 토글 닫힘 act(() => poseEditButtons()[0].click()); @@ -447,8 +456,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -456,8 +463,6 @@ describe('SingleSpritePanel 자세 편집', () => { imageOverride: null, }, { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-2', triggers: [KEY_ID_B], @@ -506,8 +511,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -515,8 +518,6 @@ describe('SingleSpritePanel 자세 편집', () => { imageOverride: null, }, { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-2', triggers: [KEY_ID_B], @@ -552,8 +553,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', name: '왼손', @@ -562,8 +561,6 @@ describe('SingleSpritePanel 자세 편집', () => { imageOverride: 'override.png', }, { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-2', triggers: [KEY_ID_B], @@ -614,8 +611,6 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [KEY_ID_C], transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, imageOverride: 'override.png', - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }, // 구조 변경 시 무명 상태는 보이던 번호가 이름으로 고정된다 @@ -628,8 +623,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', name: '왼손 common.copySuffix', @@ -661,8 +654,6 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [key], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }); const position = spritePosition({ @@ -702,8 +693,6 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [`key-${index}`], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, })); const position = spritePosition({ poses }); @@ -744,8 +733,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', name: 'propertiesPanel.spritePose 1', @@ -776,8 +763,6 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [key], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }); const position = spritePosition({ @@ -813,8 +798,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', name: 'propertiesPanel.spritePose 1', @@ -823,8 +806,6 @@ describe('SingleSpritePanel 자세 편집', () => { imageOverride: null, }, { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-2', triggers: [KEY_ID_B], @@ -855,8 +836,6 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [`key-${index}`], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, })); const position = spritePosition({ poses }); @@ -879,8 +858,6 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [KEY_ID_A], transform: { x: 10, y: -6, rotation: 15, scale: 1.2 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }; const position = spritePosition({ poses: [basePose] }); @@ -905,8 +882,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -942,8 +917,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -973,8 +946,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1009,8 +980,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A, KEY_ID_B], @@ -1039,8 +1008,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A, deadId], @@ -1071,8 +1038,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1093,17 +1058,16 @@ describe('SingleSpritePanel 자세 편집', () => { expect(trigger.className).toContain('w-full'); }); - it('이미지 설정 팝업의 미리보기로 선택과 초기화를 커밋한다', async () => { + it('기본 이미지 카드로 선택과 초기화를 커밋하고 상자를 이미지 비율로 맞춘다', async () => { const position = spritePosition({ baseImage: 'hand.png' }); seed(position); render(position); - act(() => buttonByText('propertiesPanel.configure').click()); - const popup = popupByLabel('propertiesPanel.spriteBaseImage'); - expect(popup).toBeTruthy(); + // 카드는 팝업 없이 패널 상단에 산다 + expect(posePopup()).toBeNull(); // 초기화 칩은 baseImage null 커밋 - const reset = popup!.querySelector( + const reset = container.querySelector( 'button[aria-label="imagePicker.reset"]', )!; act(() => reset.click()); @@ -1113,44 +1077,45 @@ describe('SingleSpritePanel 자세 편집', () => { referenceNaturalSize: null, }); - // 미리보기 전면 선택 버튼은 파일창 결과를 baseImage로 커밋 + // 전면 선택 버튼은 파일창 결과를 baseImage로 커밋 mocks.imageLoad.mockResolvedValue({ success: true, imagePath: 'body.png', }); - const select = popup!.querySelector( + const select = container.querySelector( 'button[aria-label="propertiesPanel.spriteImageSelect"]', )!; await act(async () => select.click()); expect(mocks.patchPosition).toHaveBeenCalledTimes(2); - // 원본 크기는 디코드 확인이 읽은 값을 경로와 한 커밋으로 싣는다 + // 원본 크기는 디코드 확인이 읽은 값을 경로와 한 커밋으로 싣고, 요소 상자를 + // 그 비율로 줄인다: 200x150에 64x32 → 200x100, 가운데 기준점 유지 → dy 25 expect(mocks.patchPosition.mock.calls[1][2]).toEqual({ baseImage: 'body.png', referenceNaturalSize: { source: 'body.png', width: 64, height: 32 }, + dx: 0, + dy: 25, + width: 200, + height: 100, }); - }); - - it('축 배치에서 기본 이미지를 고르면 상자를 이미지 비율로 맞추고 기준점은 제자리에 둔다', async () => { - const position = spritePosition({ - baseImage: 'hand.png', - imagePlacement: 'pivot', - referenceNaturalSize: { source: 'hand.png', width: 10, height: 10 }, - }); - seed(position); - render(position); - - act(() => buttonByText('propertiesPanel.configure').click()); - const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; - mocks.imageLoad.mockResolvedValue({ success: true, imagePath: 'body.png' }); - const select = popup.querySelector( - 'button[aria-label="propertiesPanel.spriteImageSelect"]', - )!; - await act(async () => select.click()); - // 상자 100x100, 이미지 64x32 → 100x50, 가운데 기준점(50,50) 유지 → y 25 - expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ + const generatePatch = mocks.patchPosition.mock.calls[1][4] as ( + current: CanonicalReactiveSpritePosition, + ) => Partial; + expect( + generatePatch( + spritePosition({ + dx: 0, + dy: 0, + width: 300, + height: 300, + pivot: { x: 0.25, y: 0.75 }, + }), + ), + ).toMatchObject({ baseImage: 'body.png', - referenceNaturalSize: { source: 'body.png', width: 64, height: 32 }, - imageRect: { x: 0, y: 25, width: 100, height: 50 }, + dx: 0, + dy: 112.5, + width: 300, + height: 150, }); }); @@ -1161,12 +1126,9 @@ describe('SingleSpritePanel 자세 편집', () => { seed(position); render(position); - act(() => buttonByText('propertiesPanel.configure').click()); - const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; + expect(container.querySelector('img')).toBeNull(); - expect(popup.querySelector('img')).toBeNull(); - - const reset = popup.querySelector( + const reset = container.querySelector( 'button[aria-label="imagePicker.reset"]', )!; expect(reset).toBeTruthy(); @@ -1177,29 +1139,37 @@ describe('SingleSpritePanel 자세 편집', () => { }); }); - it('이미지 설정 팝업의 위치·기준점 입력은 imageRect·pivot 패치로 커밋한다', () => { - const position = spritePosition(); + it('위치 입력은 요소 상자 커밋, 기준점 입력은 이동값 보정을 실은 패치로 커밋한다', () => { + const position = spritePosition({ + poses: [ + { + imageOverrideMetrics: null, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 10, y: 0, rotation: 90, scale: 1 }, + imageOverride: null, + }, + ], + }); seed(position); render(position); - act(() => buttonByText('propertiesPanel.configure').click()); - const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; - // 표시 드롭다운도 팝업으로 이동했다 - expect(popup.querySelector('button[aria-haspopup="listbox"]')).toBeTruthy(); - - const xInput = popup.querySelector( - 'input[aria-label="propertiesPanel.spriteImageRect X"]', + const xInput = container.querySelector( + 'input[aria-label="propertiesPanel.position X"]', )!; act(() => { xInput.focus(); setInputValue(xInput, '12'); }); act(() => xInput.blur()); - expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ - imageRect: { x: 12, y: 0, width: 100, height: 100 }, - }); + expect(mocks.commitBounds).toHaveBeenLastCalledWith( + 'sprite', + SPRITE_ID, + { dx: 12, dy: 0, width: 200, height: 150 }, + undefined, + ); - // 기준점은 팝업이 아니라 패널의 기본 이미지 행 아래에 산다 + // 연결 상태는 x/y를 유지해 기본 기준점의 화면 이동을 그대로 따라간다 const pivotY = container.querySelector( 'input[aria-label="propertiesPanel.spritePivot Y"]', )!; @@ -1208,17 +1178,22 @@ describe('SingleSpritePanel 자세 편집', () => { setInputValue(pivotY, '25'); }); act(() => pivotY.blur()); - expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ - pivot: { x: 0.5, y: 0.25 }, - }); + const patch = mocks.patchPosition.mock.calls.at(-1)?.[2] as { + pivot: { x: number; y: number }; + idleTransform: { x: number; y: number }; + poses: Array<{ transform: { x: number; y: number; rotation: number } }>; + }; + expect(patch.pivot).toEqual({ x: 0.5, y: 0.25 }); + expect(patch.idleTransform).toEqual({ x: 0, y: 0, rotation: 0, scale: 1 }); + expect(patch.poses[0].transform.x).toBe(10); + expect(patch.poses[0].transform.y).toBe(0); + expect(patch.poses[0].transform.rotation).toBe(90); }); - it('이미지 팝업과 자세 팝업은 배타 전환되고 자세 프리뷰는 발행·회수된다', () => { + it('자세 행 클릭은 자세 팝업을 열어 프리뷰를 발행하고 재클릭은 닫는다', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1230,29 +1205,69 @@ describe('SingleSpritePanel 자세 편집', () => { seed(position); render(position); - act(() => buttonByText('propertiesPanel.configure').click()); - expect(popupByLabel('propertiesPanel.spriteBaseImage')).toBeTruthy(); - // 이미지 설정 팝업은 캔버스 보조 표시가 없다 expect(useSpriteEditPreviewStore.getState().preview).toBeNull(); - - // 자세 행 클릭 - 이미지 팝업이 닫히고 자세 팝업으로 전환, 프리뷰 발행 act(() => poseEditButtons()[0].click()); - expect(popupByLabel('propertiesPanel.spriteBaseImage')).toBeNull(); expect(popupByLabel('propertiesPanel.spritePose 1')).toBeTruthy(); expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ kind: 'pose', positionId: SPRITE_ID, poseId: 'pose-1', }); + // 캔버스 자세 핸들 세션도 같은 자세로 발행된다 + expect(useSpritePoseHandleStore.getState().session).toMatchObject({ + positionId: SPRITE_ID, + poseId: 'pose-1', + width: 200, + height: 150, + }); - // 이미지 팝업으로 되돌아가면 자세 프리뷰는 회수 - act(() => buttonByText('propertiesPanel.configure').click()); + // 같은 행 재클릭 토글로 닫기 - 프리뷰·세션 회수 + act(() => poseEditButtons()[0].click()); + expect(posePopup()).toBeNull(); expect(useSpriteEditPreviewStore.getState().preview).toBeNull(); - expect(popupByLabel('propertiesPanel.spriteBaseImage')).toBeTruthy(); + expect(useSpritePoseHandleStore.getState().session).toBeNull(); + }); - // 같은 버튼 재클릭 토글로 닫기 - act(() => buttonByText('propertiesPanel.configure').click()); - expect(posePopup()).toBeNull(); + it('자세 이동 프리뷰는 상태 배열 IPC 없이 선택한 자세 스냅샷만 갱신하고 확정 때 한 번 저장한다', () => { + const position = spritePosition({ + poses: [ + { + imageOverrideMetrics: null, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + const session = useSpritePoseHandleStore.getState().session!; + mocks.preview.mockClear(); + mocks.patchPosition.mockClear(); + + const moved = { x: 24, y: -12, rotation: 15, scale: 1.2 }; + act(() => session.preview(moved)); + + expect(mocks.preview).not.toHaveBeenCalled(); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ + poseId: 'pose-1', + preferFallback: true, + fallbackPose: { transform: moved }, + }); + + act(() => session.commit(moved)); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + const patch = mocks.patchPosition.mock.calls[0][2] as { + poses: Array<{ poseId: string; transform: typeof moved }>; + }; + expect(patch.poses).toEqual([ + expect.objectContaining({ poseId: 'pose-1', transform: moved }), + ]); }); it('canonical 착지 전 draft 자세도 프리뷰 스냅샷으로 발행된다', () => { @@ -1277,8 +1292,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1309,11 +1322,8 @@ describe('SingleSpritePanel 자세 편집', () => { it('리사이즈 착지 시 무효 draft 자세가 같은 배율로 rebase된다', async () => { const position = spritePosition({ - imageRect: { x: 4, y: 8, width: 96, height: 64 }, poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1356,8 +1366,6 @@ describe('SingleSpritePanel 자세 편집', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1379,8 +1387,6 @@ describe('SingleSpritePanel 자세 편집', () => { const undone = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1411,11 +1417,8 @@ describe('SingleSpritePanel 자세 편집', () => { it('자세 팝업이 열린 채 리사이즈가 착지하면 진행 중 편집 게스처를 취소한다', async () => { const position = spritePosition({ - imageRect: { x: 4, y: 8, width: 96, height: 64 }, poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1433,7 +1436,7 @@ describe('SingleSpritePanel 자세 편집', () => { '[data-testid="pose-popup"]', ); expect(popupBefore).not.toBeNull(); - const generationBefore = useSpritePoseGizmoStore.getState().generation; + const generationBefore = useSpritePoseHandleStore.getState().generation; const resized = projectSpriteResize(position, { dx: 0, @@ -1452,7 +1455,7 @@ describe('SingleSpritePanel 자세 편집', () => { container.querySelector('[data-testid="pose-popup"]'), ).not.toBeNull(); // 기즈모 소유권 세대 무효화 - 진행 중 캔버스 드래그의 pointerup 커밋 차단 - expect(useSpritePoseGizmoStore.getState().generation).toBeGreaterThan( + expect(useSpritePoseHandleStore.getState().generation).toBeGreaterThan( generationBefore, ); }); @@ -1467,7 +1470,7 @@ describe('SingleSpritePanel 자세 편집', () => { triggers: [KEY_ID_A], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, }); - let capturedSession: SpritePoseGizmoSession | null = null; + let capturedSession: SpritePoseHandleSession | null = null; // 기즈모처럼 트리상 앞에서 passive effect로 시작 시점 세션의 취소를 부르는 탐침 const CancelOnTick = ({ tick }: { tick: number }) => { useEffect(() => { @@ -1511,7 +1514,7 @@ describe('SingleSpritePanel 자세 편집', () => { seed(position); renderWithProbe(position, 0); act(() => buttonByText('propertiesPanel.spriteAddPose').click()); - capturedSession = useSpritePoseGizmoStore.getState().session; + capturedSession = useSpritePoseHandleStore.getState().session; expect(capturedSession).not.toBeNull(); const draftPoseId = capturedSession!.poseId; expect(useSpriteEditPreviewStore.getState().preview).toMatchObject({ @@ -1559,11 +1562,8 @@ describe('SingleSpritePanel 자세 편집', () => { it('박스만 바뀌는 변경은 draft 자세를 건드리지 않는다', async () => { const position = spritePosition({ - imageRect: { x: 4, y: 8, width: 96, height: 64 }, poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1579,7 +1579,7 @@ describe('SingleSpritePanel 자세 편집', () => { await act(async () => triggerDropdownByText('A').click()); await act(async () => menuOptionByText('A').click()); - // legacy patch 계열: 박스 치수만 바뀌고 imageRect·자세는 그대로 + // legacy patch 계열: 상자 치수만 바뀌고 자세는 그대로 const boxOnly = { ...position, width: 100, height: 300 }; seed(boxOnly); render(boxOnly); @@ -1590,30 +1590,10 @@ describe('SingleSpritePanel 자세 편집', () => { ).toEqual({ x: 12, y: -6, rotation: 15, scale: 1.2 }); }); - it('기준점 행을 만지는 동안 축 마커가 발행되고 벗어나면 회수된다', () => { - const position = spritePosition(); - seed(position); - render(position); - - const pivotX = container.querySelector( - 'input[aria-label="propertiesPanel.spritePivot X"]', - )!; - act(() => pivotX.focus()); - expect(useSpriteEditPreviewStore.getState().preview).toEqual({ - kind: 'pivot', - positionId: SPRITE_ID, - }); - - act(() => pivotX.blur()); - expect(useSpriteEditPreviewStore.getState().preview).toBeNull(); - }); - it('스프라이트 전환 시 자세 프리뷰가 회수된다', () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1637,16 +1617,13 @@ describe('SingleSpritePanel 자세 편집', () => { seed(position); render(position); - act(() => buttonByText('propertiesPanel.configure').click()); let resolveLoad!: (value: { success: boolean; imagePath?: string }) => void; mocks.imageLoad.mockReturnValue( new Promise((resolve) => { resolveLoad = resolve; }) as never, ); - const select = popupByLabel( - 'propertiesPanel.spriteBaseImage', - )!.querySelector( + const select = container.querySelector( 'button[aria-label="propertiesPanel.spriteImageSelect"]', )!; act(() => select.click()); @@ -1660,15 +1637,13 @@ describe('SingleSpritePanel 자세 편집', () => { expect(mocks.patchPosition).not.toHaveBeenCalled(); }); - it('이미지 설정 입력은 preview로 흐르고 Escape는 게스처를 취소한다', async () => { + it('위치 입력은 이동 필드만 preview에 흐르고 Escape는 게스처를 취소한다', async () => { const position = spritePosition(); seed(position); render(position); - act(() => buttonByText('propertiesPanel.configure').click()); - const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; - const xInput = popup.querySelector( - 'input[aria-label="propertiesPanel.spriteImageRect X"]', + const xInput = container.querySelector( + 'input[aria-label="propertiesPanel.position X"]', )!; act(() => { xInput.focus(); @@ -1680,7 +1655,8 @@ describe('SingleSpritePanel 자세 편집', () => { (call) => (call[1] as Array<{ patch: Record }>)[0].patch, ); expect(previewPatches.at(-1)).toEqual({ - imageRect: { x: 12, y: 0, width: 100, height: 100 }, + dx: 12, + dy: 0, }); act(() => { @@ -1691,47 +1667,155 @@ describe('SingleSpritePanel 자세 편집', () => { expect(mocks.gestureCancel).toHaveBeenCalled(); }); - it('크기 입력은 1 미만을 막고 표시 변경은 imageFit으로 커밋한다', async () => { + it('오래된 표시 기준점에서 한 축만 바꿔도 다른 축은 canonical 값을 보존한다', () => { + const canonical = spritePosition({ pivot: { x: 0.8, y: 0.7 } }); + const staleDisplay = spritePosition({ pivot: { x: 0.1, y: 0.2 } }); + seed(canonical); + render(staleDisplay); + + const pivotX = container.querySelector( + 'input[aria-label="propertiesPanel.spritePivot X"]', + )!; + act(() => { + pivotX.focus(); + setInputValue(pivotX, '40'); + }); + act(() => pivotX.blur()); + + const patch = mocks.patchPosition.mock.calls.at(-1)?.[2] as { + pivot: { x: number; y: number }; + }; + expect(patch.pivot).toEqual({ x: 0.4, y: 0.7 }); + }); + + it('오래된 상태 변환에서 한 축만 바꿔도 나머지는 canonical 값을 보존한다', () => { + const canonical = spritePosition({ + poses: [ + { + imageOverrideMetrics: null, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 10, y: 20, rotation: 15, scale: 1.2 }, + imageOverride: null, + }, + ], + }); + const staleDisplay = { + ...canonical, + poses: [ + { + ...canonical.poses[0], + transform: { x: -5, y: -6, rotation: -10, scale: 0.8 }, + }, + ], + }; + seed(canonical); + render(staleDisplay); + act(() => poseEditButtons()[0].click()); + + const transformX = posePopup()!.querySelector( + 'input[aria-label="propertiesPanel.position X"]', + )!; + act(() => { + transformX.focus(); + setInputValue(transformX, '30'); + }); + act(() => transformX.blur()); + + const patch = mocks.patchPosition.mock.calls.at(-1)?.[2] as { + poses: Array<{ transform: unknown }>; + }; + expect(patch.poses[0].transform).toEqual({ + x: 30, + y: 20, + rotation: 15, + scale: 1.2, + }); + }); + + it('오래된 상태 기준점에서 한 축만 바꿔도 다른 축은 canonical 값을 보존한다', () => { + const canonical = spritePosition({ + poses: [ + { + imageOverrideMetrics: null, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 10, y: 20, rotation: 15, scale: 1.2 }, + pivot: { x: 0.8, y: 0.7 }, + imageOverride: null, + }, + ], + }); + const staleDisplay = { + ...canonical, + poses: [ + { + ...canonical.poses[0], + pivot: { x: 0.1, y: 0.2 }, + }, + ], + }; + seed(canonical); + render(staleDisplay); + act(() => poseEditButtons()[0].click()); + + const pivotX = posePopup()!.querySelector( + 'input[aria-label="propertiesPanel.spriteStatePivot X"]', + )!; + act(() => { + pivotX.focus(); + setInputValue(pivotX, '40'); + }); + act(() => pivotX.blur()); + + const patch = mocks.patchPosition.mock.calls.at(-1)?.[2] as { + poses: Array<{ pivot: { x: number; y: number } }>; + }; + expect(patch.poses[0].pivot).toEqual({ x: 0.4, y: 0.7 }); + }); + + it('크기 입력은 비율을 고정하고 하한을 막는다', () => { const position = spritePosition(); seed(position); render(position); - act(() => buttonByText('propertiesPanel.configure').click()); - const popup = popupByLabel('propertiesPanel.spriteBaseImage')!; - const wInput = popup.querySelector( - 'input[aria-label="propertiesPanel.spriteImageSize W"]', + // 200x150 → W 0은 하한 10으로, H는 비율(0.75)을 따라 7.5 + const wInput = container.querySelector( + 'input[aria-label="propertiesPanel.size W"]', )!; act(() => { wInput.focus(); setInputValue(wInput, '0'); }); act(() => wInput.blur()); - expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ - imageRect: { x: 0, y: 0, width: 1, height: 100 }, - }); - - // 설정 카드의 드롭다운은 배치·표시 순서 - 표시는 두 번째 - await act(async () => - popup - .querySelectorAll( - 'button[aria-haspopup="listbox"]', - )[1] - .click(), + expect(mocks.commitBounds).toHaveBeenLastCalledWith( + 'sprite', + SPRITE_ID, + { dx: 0, dy: 0, width: 10, height: 7.5 }, + undefined, ); - await act(async () => - menuOptionByText('propertiesPanel.imageFitFill').click(), + + // H 300 → W 400 (비율 유지, 상자 원점은 그대로) + const hInput = container.querySelector( + 'input[aria-label="propertiesPanel.size H"]', + )!; + act(() => { + hInput.focus(); + setInputValue(hInput, '300'); + }); + act(() => hInput.blur()); + expect(mocks.commitBounds).toHaveBeenLastCalledWith( + 'sprite', + SPRITE_ID, + { dx: 0, dy: 0, width: 400, height: 300 }, + undefined, ); - expect(mocks.patchPosition.mock.calls.at(-1)?.[2]).toEqual({ - imageFit: 'fill', - }); }); it('자세 이미지 선택은 파일창 결과를 자세에 커밋한다', async () => { const position = spritePosition({ poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [KEY_ID_A], @@ -1765,5 +1849,60 @@ describe('SingleSpritePanel 자세 편집', () => { width: 64, height: 32, }); + const generatePatch = mocks.patchPosition.mock.calls[0][4] as ( + current: CanonicalReactiveSpritePosition, + ) => Partial | null; + expect(generatePatch(spritePosition({ poses: [] }))).toBeNull(); + }); + + it('기준점 커밋 착지는 무효 draft를 같은 보정으로 rebase해 편집 중인 값을 지키지 않는다', async () => { + const position = spritePosition({ + poses: [ + { + imageOverrideMetrics: null, + poseId: 'pose-1', + triggers: [KEY_ID_A], + transform: { x: 10, y: 0, rotation: 90, scale: 1 }, + imageOverride: null, + }, + ], + }); + seed(position); + render(position); + + act(() => poseEditButtons()[0].click()); + // 유일한 담당 키 해제 - 커밋이 막힌 빈 트리거 draft가 transform을 들고 있다 + await act(async () => triggerDropdownByText('A').click()); + await act(async () => menuOptionByText('A').click()); + mocks.patchPosition.mockClear(); + + // 기준점 프리셋 (0.5, 0.25)는 없으니 Y 입력으로 - canonical 자세를 보정한 patch + const pivotY = container.querySelector( + 'input[aria-label="propertiesPanel.spritePivot Y"]', + )!; + act(() => { + pivotY.focus(); + setInputValue(pivotY, '25'); + }); + act(() => pivotY.blur()); + const patch = mocks.patchPosition.mock.calls.at(-1)?.[2] as Pick< + CanonicalReactiveSpritePosition, + 'pivot' | 'idleTransform' | 'poses' + >; + expect(patch.pivot).toEqual({ x: 0.5, y: 0.25 }); + // draft(빈 트리거)가 아니라 canonical 자세가 실린다 + expect(patch.poses[0].triggers).toEqual([KEY_ID_A]); + + // 낙관 착지 - canonical이 보정된 자세와 새 기준점으로 바뀐다 + const landed = { ...position, ...patch }; + seed(landed); + render(landed); + + // draft는 버려지지 않고 같은 보정을 받는다: 트리거는 여전히 비어 있고 + // transform은 canonical과 같은 값으로 이동했다 + const preview = useSpriteEditPreviewStore.getState().preview; + expect(preview?.preferFallback).toBe(true); + expect(preview?.fallbackPose.triggers).toEqual([]); + expect(preview?.fallbackPose.transform).toEqual(patch.poses[0].transform); }); }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.test.tsx index f405588d3..86f6e69c9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpriteImagePreviewCard.test.tsx @@ -25,7 +25,6 @@ describe('SpriteImagePreviewCard', () => { root.render( key} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts index 5c7759392..9ea00f194 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/spriteCompilerGuard.test.ts @@ -30,9 +30,9 @@ describe('스프라이트 편집 컴파일러 가드', () => { expect(compile(path)).toContain('react/compiler-runtime'); }); - it("SpritePoseGizmo는 'use no memo'로 제외를 명시한다", () => { + it("SpriteCanvasHandles는 'use no memo'로 제외를 명시한다", () => { const path = - 'src/renderer/components/main/Grid/handles/SpritePoseGizmo.tsx'; + 'src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx'; expect(readFileSync(resolve(process.cwd(), path), 'utf8')).toMatch( /^'use no memo';/, ); diff --git a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx index 92facb413..cd0e4df0d 100644 --- a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx @@ -342,3 +342,24 @@ describe('ResizeHandles 핸들 호버 생명주기', () => { expect(hasCursorBodyClass()).toBe(false); }); }); + +describe('ResizeHandles 자리 양보', () => { + it('occupiedHandle 자리의 핸들은 그리지 않고 나머지 7개만 그린다', async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + await act(async () => { + root.render( + , + ); + }); + expect(host.querySelector('[data-resize-handle="ne"]')).toBeNull(); + expect(host.querySelectorAll('[data-resize-handle]').length).toBe(7); + await act(async () => root.unmount()); + host.remove(); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx new file mode 100644 index 000000000..1f2c23eb5 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.integration.test.tsx @@ -0,0 +1,465 @@ +// @vitest-environment jsdom +import React, { act, useSyncExternalStore } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CanonicalReactiveSpritePosition, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorDocumentV1, +} from '@src/types/editor'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const KEY_ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +// IPC 경계만 대역. 게스처 컨트롤러·프리뷰 오버레이·스토어·coordinator는 실물이라 +// 단위 테스트가 mock으로 가려 두던 "드래그 중 canonical 교체" 경로까지 지나간다 +const runtime = vi.hoisted(() => { + let resolveCommit!: (result: EditorCommitResult) => void; + const commit = vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolveCommit = resolve; + }), + ); + const get = vi.fn(); + let committedListener: ((event: EditorCommittedV1) => void) | null = null; + const onCommitted = vi.fn((listener: (event: EditorCommittedV1) => void) => { + committedListener = listener; + return Object.assign(() => {}, { ready: Promise.resolve() }); + }); + return { + commit, + get, + onCommitted, + emitCommitted: (event: EditorCommittedV1) => committedListener?.(event), + resolveCommit: (result: EditorCommitResult) => resolveCommit(result), + previewPublish: vi.fn(async () => {}), + previewCancel: vi.fn(async () => {}), + previewSubscribe: vi.fn(async () => 1), + }; +}); + +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); +vi.mock('@api/modules/previewApi', () => ({ + previewApi: { + publish: runtime.previewPublish, + cancel: runtime.previewCancel, + subscribe: runtime.previewSubscribe, + }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const spriteFixture = ( + overrides: Partial = {}, +): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + poseId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + triggers: [KEY_ID_A], + transform: { x: 10, y: 0, rotation: 90, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + }, + ], + transitionMs: 90, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, + ...overrides, +}); + +const makeDocument = ( + sprite: CanonicalReactiveSpritePosition, +): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: {}, + keyPositions: {}, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: { '4key': [sprite] }, + layerGroups: {}, +}); + +const loadHarness = async () => { + const [handles, overlay, spriteStore, coordinator, dragSession] = + await Promise.all([ + import('./SpriteCanvasHandles'), + import('@src/renderer/editor/runtime/previewOverlay'), + import('@stores/data/useSpriteStore'), + import('@src/renderer/editor/runtime/editorStateCoordinator'), + import('@hooks/Grid/dragSession'), + ]); + return { + SpriteCanvasHandles: handles.default, + composePreviewPositions: overlay.composePreviewPositions, + previewOverlay: overlay.previewOverlay, + subscribePreviewOverlay: overlay.subscribePreviewOverlay, + getPreviewOverlayVersion: overlay.getPreviewOverlayVersion, + useSpriteStore: spriteStore.useSpriteStore, + editorCoordinator: coordinator.editorCoordinator, + releaseDragSession: dragSession.releaseDragSession, + }; +}; +type Harness = Awaited>; + +describe('SpriteCanvasHandles 기준점 드래그 통합', () => { + let harness: Harness; + let container: HTMLDivElement; + let root: Root; + const selected: SelectedElement[] = [{ type: 'sprite', id: SPRITE_ID }]; + + // Grid와 같은 합성: canonical 위에 프리뷰 오버레이를 얹어 핸들에 넘긴다 + const Host = () => { + const canonical = harness.useSpriteStore((state) => state.positions); + useSyncExternalStore( + harness.subscribePreviewOverlay, + harness.getPreviewOverlayVersion, + ); + const spritePositions = harness.composePreviewPositions( + 'spritePosition', + canonical, + ); + return ( + + ); + }; + + const render = () => act(() => root.render()); + const pivotHandle = () => + container.querySelector('[data-sprite-pivot-handle="true"]')!; + const pointer = ( + type: string, + target: EventTarget, + clientX: number, + clientY: number, + init: { ctrlKey?: boolean } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX, + clientY, + pointerId: 1, + ctrlKey: init.ctrlKey ?? false, + }), + ); + }); + const handleCenterX = () => parseFloat(pivotHandle().style.left) + 13; + // rAF 스케줄러가 setTimeout 0으로 스텁돼 있어 한 틱 기다리면 move가 반영된다 + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + + beforeEach(async () => { + vi.resetModules(); + runtime.commit.mockClear(); + runtime.previewPublish.mockClear(); + runtime.previewCancel.mockClear(); + runtime.get.mockReset(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + harness = await loadHarness(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + harness.releaseDragSession(); + harness.editorCoordinator.stop(); + vi.unstubAllGlobals(); + }); + + it('중앙에 스냅된 기준점을 드래그하면 표식이 따라가고 커밋 뒤에도 유지된다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 중앙 (100, 75) → 히트 26 좌상단 (87, 62) + expect(pivotHandle().style.left).toBe('87px'); + expect(pivotHandle().style.top).toBe('62px'); + + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 160, 75); + await settle(); + // pivot x = 0.8 → 축 160 + 프레임 보정 0.6 → 히트 좌상단 147.6 + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(147.6, 3); + + pointer('pointermove', window, 180, 90); + await settle(); + // 두 번째 move도 살아 있다 - 드래그가 도중에 취소되지 않았다 + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.8, 3); + expect(parseFloat(pivotHandle().style.top)).toBeCloseTo(77.2, 3); + + pointer('pointerup', window, 180, 90); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + const canonical = harness.useSpriteStore.getState().positions['4key'][0]; + expect(canonical.pivot).toEqual({ x: 0.9, y: 0.6 }); + // 표식은 드래그를 놓은 자리에 남는다 + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.8, 3); + + runtime.resolveCommit({ revision: 1, changedFields: ['spritePositions'] }); + await settle(); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(167.8, 3); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.9, y: 0.6 }); + }); + + it('직전 커밋이 착지한 직후 중앙에서 시작한 두 번째 드래그도 이어진다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture({ pivot: { x: 1, y: 0 } })), + }); + await harness.editorCoordinator.start(); + render(); + + // 1차: 모서리 → 중앙으로 드래그해 스냅 + pointer('pointerdown', pivotHandle(), 201, -1); + pointer('pointermove', window, 102, 77); + await settle(); + pointer('pointerup', window, 102, 77); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.5, y: 0.5 }); + runtime.resolveCommit({ revision: 1, changedFields: ['spritePositions'] }); + await settle(); + expect(pivotHandle().style.left).toBe('87px'); + + // 2차: 중앙에서 바로 다른 곳으로 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75); + await settle(); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(127.4, 3); + + pointer('pointermove', window, 160, 75); + await settle(); + expect(parseFloat(pivotHandle().style.left)).toBeCloseTo(147.6, 3); + + pointer('pointerup', window, 160, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.8, y: 0.5 }); + }); + + it('배율 0.1 idle 스프라이트도 표식이 포인터를 그대로 따라간다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument( + spriteFixture({ + idleTransform: { x: 0, y: 0, rotation: 0, scale: 0.1 }, + }), + ), + }); + await harness.editorCoordinator.start(); + render(); + expect(handleCenterX()).toBeCloseTo(100, 6); + + pointer('pointerdown', pivotHandle(), 100, 75); + // 스냅이 켜진 채 1px - 프리셋 자리가 7.5px 간격으로 몰려도 옆 프리셋으로 튀지 않는다 + pointer('pointermove', window, 100, 74); + await settle(); + expect(handleCenterX()).toBeCloseTo(100, 6); + expect(parseFloat(pivotHandle().style.top) + 13).toBeCloseTo(75, 6); + // 스냅 해제 상태로 5px - 배율 0.1이라 기준점은 0.25 움직이고 표식은 포인터 아래 + pointer('pointermove', window, 105, 75, { ctrlKey: true }); + await settle(); + // 105 + 프레임 보정 (2·0.75−1)·1 = 105.5 + expect(handleCenterX()).toBeCloseTo(105.5, 6); + + pointer('pointerup', window, 105, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + const canonical = harness.useSpriteStore.getState().positions['4key'][0]; + expect(canonical.pivot.x).toBeCloseTo(0.75, 9); + expect(canonical.pivot.y).toBeCloseTo(0.5, 9); + // 그림은 움직이지 않는다 - t' = t + (P − P') + sR·Δ = 0 + (100 − 150) + 0.1·50 + expect(canonical.idleTransform.x).toBeCloseTo(-45, 9); + expect(handleCenterX()).toBeCloseTo(105.5, 6); + }); + + it('히트 영역 가장자리를 잡아도 첫 move에서 표식이 튀지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 중심에서 8px 오른쪽을 잡고 4px 이동 → 표식은 4px만 따라온다 + pointer('pointerdown', pivotHandle(), 108, 75); + pointer('pointermove', window, 112, 75, { ctrlKey: true }); + await settle(); + // 축 104 + 프레임 보정 (2·0.52−1)·1 = 104.04 + expect(handleCenterX()).toBeCloseTo(104.04, 6); + pointer('pointerup', window, 112, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot.x, + ).toBeCloseTo(0.52, 9); + }); + + it('표시 기준점이 canonical과 다르면(다른 프리뷰 진행 중) 드래그를 시작하지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + const { editGestureController } = await import( + '@src/renderer/editor/runtime/editGestureController' + ); + // 다른 컨트롤의 프리뷰가 기준점을 0.75로 보여 주는 상태 + act(() => + editGestureController.preview( + '4key', + [ + { + id: SPRITE_ID, + patch: { + pivot: { x: 0.75, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + }, + }, + ], + { domain: 'spritePosition' }, + ), + ); + await settle(); + expect(handleCenterX()).toBeCloseTo(150.5, 6); + + pointer('pointerdown', pivotHandle(), 150, 75); + pointer('pointermove', window, 120, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 120, 75); + await settle(); + expect(runtime.commit).not.toHaveBeenCalled(); + expect(handleCenterX()).toBeCloseTo(150.5, 6); + act(() => editGestureController.cancel()); + }); + + it('컨트롤러가 잃어버린 로컬 프리뷰가 기준점을 가려도 한 번의 드래그로 회수하고 저장한다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + const orphanSessionId = '11111111-1111-4111-8111-111111111111'; + act(() => { + harness.previewOverlay.applyLocalPatchByIds( + orphanSessionId, + '4key', + [SPRITE_ID], + { + pivot: { x: 0.75, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + }, + 'spritePosition', + ); + }); + await settle(); + expect(handleCenterX()).toBeCloseTo(150.5, 6); + + pointer('pointerdown', pivotHandle(), 150, 75); + pointer('pointermove', window, 120, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 120, 75); + await settle(); + + expect(runtime.previewCancel).toHaveBeenCalledWith(orphanSessionId); + expect(runtime.commit).toHaveBeenCalledOnce(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot.x, + ).toBeCloseTo(0.6, 9); + expect(handleCenterX()).toBeCloseTo(120.2, 6); + }); + + it('다른 프리뷰가 상자 크기만 바꿔 보여 줘도 드래그를 시작하지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + const { editGestureController } = await import( + '@src/renderer/editor/runtime/editGestureController' + ); + // 기준점·transform은 canonical 그대로, 상자만 두 배로 보이는 프리뷰 + act(() => + editGestureController.preview( + '4key', + [{ id: SPRITE_ID, patch: { width: 400, height: 300 } }], + { domain: 'spritePosition' }, + ), + ); + await settle(); + // 표시 표식은 두 배 상자의 중앙 (200, 150) + expect(handleCenterX()).toBeCloseTo(200, 6); + + pointer('pointerdown', pivotHandle(), 200, 150); + pointer('pointermove', window, 200, 150, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 200, 150); + await settle(); + expect(runtime.commit).not.toHaveBeenCalled(); + expect( + harness.useSpriteStore.getState().positions['4key'][0].pivot, + ).toEqual({ x: 0.5, y: 0.5 }); + act(() => editGestureController.cancel()); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx new file mode 100644 index 000000000..088e5871c --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx @@ -0,0 +1,903 @@ +// @vitest-environment jsdom +import React, { act, createRef, useSyncExternalStore } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + CanonicalReactiveSpritePosition, + EditorCommitRequest, + EditorCommitResult, + EditorCommittedV1, + EditorDocumentV1, +} from '@src/types/editor'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const POSE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const KEY_ID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + +// 패널 + 캔버스 핸들 + 실물 게스처 컨트롤러·오버레이·스토어·coordinator. +// IPC와 리소스 조회만 대역. "기준점을 옮긴 뒤 자세를 열어 캔버스에서 끌면 +// 제자리로 돌아온다"는 실기 보고를 그대로 재현한다 +const runtime = vi.hoisted(() => { + const resolvers: Array<(result: EditorCommitResult) => void> = []; + let committedListener: ((event: EditorCommittedV1) => void) | null = null; + const commit = vi.fn( + (_request: EditorCommitRequest) => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const get = vi.fn(); + const onCommitted = vi.fn((listener: (event: EditorCommittedV1) => void) => { + committedListener = listener; + return Object.assign(() => {}, { ready: Promise.resolve() }); + }); + return { + commit, + get, + onCommitted, + emitCommittedForCall: (index: number, revision: number) => { + const request = commit.mock.calls[index]?.[0]; + if (!request?.changes) throw new Error('Missing commit request'); + committedListener?.({ + schemaVersion: 1, + revision, + mutationId: request.mutationId, + changedFields: ['spritePositions'], + patch: { ...request.changes, schemaVersion: 1 }, + gestureId: request.gestureId, + gestureIds: request.gestureId ? [request.gestureId] : [], + }); + }, + // 케이스 사이에 남은 resolver를 비운다 - 이전 케이스의 보류 저장이 다음 케이스의 + // resolveNext를 가로채면 직렬 큐가 멈춘 것처럼 보인다 + reset: () => { + resolvers.length = 0; + }, + resolveNext: (revision: number) => { + const resolve = resolvers.shift(); + if (!resolve) throw new Error('Missing commit resolver'); + resolve({ revision, changedFields: ['spritePositions'] }); + }, + resolveAll: () => { + let revision = 1; + while (resolvers.length > 0) { + resolvers.shift()!({ + revision: revision++, + changedFields: ['spritePositions'], + }); + } + }, + previewPublish: vi.fn(async () => {}), + previewCancel: vi.fn(async () => {}), + previewSubscribe: vi.fn(async () => 1), + }; +}); + +vi.mock('@api/modules/editorApi', () => ({ + editorApi: { + get: runtime.get, + commit: runtime.commit, + onCommitted: runtime.onCommitted, + }, +})); +vi.mock('@api/modules/previewApi', () => ({ + previewApi: { + publish: runtime.previewPublish, + cancel: runtime.previewCancel, + subscribe: runtime.previewSubscribe, + }, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@api/modules/resourceApi', () => ({ + imageApi: { load: vi.fn(() => Promise.resolve({ success: false })) }, +})); +vi.mock('@utils/core/assetProbe', () => ({ + canDecodeImage: vi.fn(() => Promise.resolve(true)), + probeImageSize: vi.fn(() => Promise.resolve({ width: 64, height: 32 })), + canLoadFont: vi.fn(() => Promise.resolve(true)), +})); +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ scrollContainerRef: vi.fn() }), +})); +vi.mock('@components/main/Grid/PropertiesPanel/PickerSurface', () => ({ + default: ({ + open, + children, + ariaLabel, + }: { + open: boolean; + children: React.ReactNode; + ariaLabel: string; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +class ResizeObserverStub { + observe() {} + disconnect() {} +} + +// NumberInput은 네이티브 value setter + input 이벤트로 타이핑을 흉내 낸다 +const setInputValue = (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); +}; + +const spriteFixture = (): CanonicalReactiveSpritePosition => ({ + activation: 'whileHeld', + pressDurationMs: 300, + id: SPRITE_ID, + dx: 0, + dy: 0, + width: 200, + height: 150, + hidden: false, + zIndex: null, + className: null, + useInlineStyles: null, + baseImage: null, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, + poses: [ + { + poseId: POSE_ID, + triggers: [KEY_ID_A], + transform: { x: 0, y: 0, rotation: 8.8, scale: 1 }, + imageOverride: null, + imageOverrideMetrics: null, + }, + ], + transitionMs: 0, + transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, +}); + +const makeDocument = ( + sprite: CanonicalReactiveSpritePosition, +): EditorDocumentV1 => ({ + schemaVersion: 1, + keys: {}, + keyPositions: {}, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: { '4key': [sprite] }, + layerGroups: {}, +}); + +const loadHarness = async () => { + const [handles, panel, overlay, spriteStore, coordinator, dragSession] = + await Promise.all([ + import('./SpriteCanvasHandles'), + import('@components/main/Grid/PropertiesPanel/single/SingleSpritePanel'), + import('@src/renderer/editor/runtime/previewOverlay'), + import('@stores/data/useSpriteStore'), + import('@src/renderer/editor/runtime/editorStateCoordinator'), + import('@hooks/Grid/dragSession'), + ]); + return { + SpriteCanvasHandles: handles.default, + SingleSpritePanel: panel.SingleSpritePanel, + composePreviewPositions: overlay.composePreviewPositions, + previewOverlay: overlay.previewOverlay, + subscribePreviewOverlay: overlay.subscribePreviewOverlay, + getPreviewOverlayVersion: overlay.getPreviewOverlayVersion, + useSpriteStore: spriteStore.useSpriteStore, + editorCoordinator: coordinator.editorCoordinator, + releaseDragSession: dragSession.releaseDragSession, + }; +}; +type Harness = Awaited>; + +describe('자세 편집 세션 통합 (패널 + 캔버스 핸들)', () => { + let harness: Harness; + let container: HTMLDivElement; + let root: Root; + let errors: string[]; + const selected: SelectedElement[] = [{ type: 'sprite', id: SPRITE_ID }]; + + const Host = () => { + const canonical = harness.useSpriteStore((state) => state.positions); + useSyncExternalStore( + harness.subscribePreviewOverlay, + harness.getPreviewOverlayVersion, + ); + const spritePositions = harness.composePreviewPositions( + 'spritePosition', + canonical, + ); + const position = spritePositions['4key']?.find( + (candidate) => candidate.id === SPRITE_ID, + ); + if (!position) return null; + return ( + <> + () as never} + renameValue="" + setRenameValue={vi.fn()} + renameCancelledRef={{ current: false }} + handleRenameCommit={vi.fn()} + handleRenameCancel={vi.fn()} + handleRenameStart={vi.fn()} + singleScrollRefFor={() => vi.fn()} + t={((key: string) => key) as never} + /> + + + ); + }; + + const render = () => act(() => root.render()); + const pivotHandle = () => + container.querySelector('[data-sprite-pivot-handle="true"]')!; + const poseFrame = () => + container.querySelector( + '[data-sprite-pose-frame="true"]', + ); + const poseRows = () => + [...container.querySelectorAll('[role="button"]')].filter( + (row) => + row.textContent?.startsWith('propertiesPanel.spritePose ') ?? false, + ); + const canonicalSprite = () => + harness.useSpriteStore.getState().positions['4key'][0]; + const pointer = ( + type: string, + target: EventTarget, + clientX: number, + clientY: number, + init: { buttons?: number; ctrlKey?: boolean } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: init.buttons ?? (type === 'pointerup' ? 0 : 1), + clientX, + clientY, + pointerId: 1, + ctrlKey: init.ctrlKey ?? false, + }), + ); + }); + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + const framePoints = () => + poseFrame()! + .getAttribute('points')! + .split(' ') + .map((pair) => pair.split(',').map(Number)); + const pivotCenter = () => ({ + x: Number.parseFloat(pivotHandle().style.left) + 13, + y: Number.parseFloat(pivotHandle().style.top) + 13, + }); + + beforeEach(async () => { + vi.resetModules(); + runtime.commit.mockClear(); + runtime.get.mockReset(); + runtime.reset(); + errors = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(' ')); + }); + vi.stubGlobal('ResizeObserver', ResizeObserverStub); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + harness = await loadHarness(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + harness.releaseDragSession(); + harness.editorCoordinator.stop(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('기준점을 옮긴 뒤 자세를 열어 캔버스에서 끌면 자세가 새 자리에 남는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 1. 기준점을 중앙 (100, 75)에서 (140, 75)로 - 스냅 해제 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().pivot.x).toBeCloseTo(0.7, 9); + const baseAxisAfter = pivotCenter(); + // 기본 선택 표식은 1px 선택 테두리 프레임에 놓여 x가 0.4px 바깥이다 + expect(baseAxisAfter.x).toBeCloseTo(140.4, 6); + expect(baseAxisAfter.y).toBeCloseTo(75, 6); + const linkedTransform = canonicalSprite().poses[0].transform; + // 연결 상태는 이동값을 유지해 기준점 화면 좌표가 기본 축을 따라간다 + expect(linkedTransform).toEqual({ + x: 0, + y: 0, + rotation: 8.8, + scale: 1, + }); + runtime.resolveAll(); + await settle(); + + // 2. 자세 행 클릭 → 팝업·세션 + expect(poseRows().length).toBe(1); + act(() => poseRows()[0].click()); + await settle(); + expect(poseFrame()).not.toBeNull(); + const linkedAxis = pivotCenter(); + expect(linkedAxis.x).toBeCloseTo(140, 6); + expect(linkedAxis.y).toBeCloseTo(baseAxisAfter.y, 6); + const before = framePoints(); + + // 3. 자세 프레임 본체를 (60, 60)에서 (90, 80)으로 끌기 + pointer('pointerdown', poseFrame()!, 60, 60); + // 일부 WebView 합성 입력은 활성 드래그에서도 buttons를 0으로 전달한다 + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + const during = framePoints(); + expect(during[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(during[0][1] - before[0][1]).toBeCloseTo(20, 6); + + pointer('pointerup', window, 90, 80); + await settle(); + // 커밋이 나갔고 canonical 자세 이동값이 30, 20만큼 늘었다 + expect(runtime.commit).toHaveBeenCalledTimes(2); + const moved = canonicalSprite().poses[0].transform; + expect(moved.x - linkedTransform.x).toBeCloseTo(30, 6); + expect(moved.y - linkedTransform.y).toBeCloseTo(20, 6); + // 프레임도 새 자리에 남는다 (제자리 복귀 없음) + const after = framePoints(); + expect(after[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(after[0][1] - before[0][1]).toBeCloseTo(20, 6); + + runtime.resolveAll(); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect( + canonicalSprite().poses[0].transform.x - linkedTransform.x, + ).toBeCloseTo(30, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('pointerup 없이 mouseup만 와도 자세 이동을 저장하고 드래그를 끝낸다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + const before = framePoints(); + + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + act(() => { + window.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + }), + ); + }); + await settle(); + + expect(runtime.commit).toHaveBeenCalledOnce(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + + // 해제 후 마우스만 옮겨도 닫힌 드래그가 다시 움직이지 않음 + pointer('pointermove', window, 140, 120, { buttons: 0 }); + await settle(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + expect(runtime.commit).toHaveBeenCalledOnce(); + + runtime.resolveAll(); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('눌린 move 뒤 buttons 0 move로 끝나도 자세 이동을 저장한다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + const before = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 80, 70, { buttons: 1 }); + await settle(); + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + + expect(runtime.commit).toHaveBeenCalledOnce(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + + pointer('pointermove', window, 140, 120, { buttons: 0 }); + await settle(); + expect(runtime.commit).toHaveBeenCalledOnce(); + runtime.resolveAll(); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + }); + + it('이전 로컬 프리뷰가 남아 있어도 새 자세 이동 커밋을 다시 가리지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + const stalePose = { + ...canonicalSprite().poses[0], + transform: { + ...canonicalSprite().poses[0].transform, + x: 20, + }, + }; + act(() => { + harness.previewOverlay.applyLocalPatchByIds( + '11111111-1111-4111-8111-111111111111', + '4key', + [SPRITE_ID], + { poses: [stalePose] }, + 'spritePosition', + ); + }); + await settle(); + + act(() => poseRows()[0].click()); + await settle(); + const before = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + + expect(runtime.commit).toHaveBeenCalledOnce(); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 50, y: 20 }), + ); + runtime.resolveAll(); + await settle(); + + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 50, y: 20 }), + ); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('기준점 저장 응답을 기다리지 않고 자세를 움직여도 새 자리에 남는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + const compensated = canonicalSprite().poses[0].transform; + const before = framePoints(); + + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + pointer('pointerup', window, 90, 80); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + runtime.resolveAll(); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect(canonicalSprite().poses[0].transform.x - compensated.x).toBeCloseTo( + 30, + 6, + ); + runtime.resolveAll(); + await settle(); + expect(canonicalSprite().poses[0].transform.x - compensated.x).toBeCloseTo( + 30, + 6, + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + }); + + it('기본 기준점과 자세 기준점 저장이 차례로 대기해도 후속 자세 이동을 유지한다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + act(() => poseRows()[0].click()); + await settle(); + const beforePosePivot = framePoints(); + const posePivot = pivotCenter(); + pointer('pointerdown', pivotHandle(), posePivot.x, posePivot.y); + pointer('pointermove', window, posePivot.x + 20, posePivot.y + 10, { + ctrlKey: true, + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + pointer('pointerup', window, posePivot.x + 20, posePivot.y + 10); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + const afterPosePivot = framePoints(); + afterPosePivot.forEach((point, index) => { + expect(point[0]).toBeCloseTo(beforePosePivot[index][0], 6); + expect(point[1]).toBeCloseTo(beforePosePivot[index][1], 6); + }); + + const before = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80, { buttons: 0 }); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + + act(() => { + runtime.emitCommittedForCall(0, 1); + runtime.resolveNext(1); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect(canonicalSprite().poses[0].pivot).toEqual( + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + }), + ); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + + act(() => { + runtime.emitCommittedForCall(1, 2); + runtime.resolveNext(2); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + + act(() => { + runtime.emitCommittedForCall(2, 3); + runtime.resolveNext(3); + }); + await settle(); + expect(framePoints()[0][0] - before[0][0]).toBeCloseTo(30, 6); + expect(framePoints()[0][1] - before[0][1]).toBeCloseTo(20, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('자세 이동과 독립 기준점 저장이 대기 중이어도 후속 기본 기준점 변경이 둘을 덮지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 1. 자세 이동 저장을 응답 대기 상태로 둔다 + act(() => poseRows()[0].click()); + await settle(); + const initialFrame = framePoints(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().poses[0].transform).toEqual( + expect.objectContaining({ x: 30, y: 20 }), + ); + + // 2. 첫 저장을 기다리지 않고 자세 기준점을 독립 위치로 옮긴다 + const statePivot = pivotCenter(); + pointer('pointerdown', pivotHandle(), statePivot.x, statePivot.y); + pointer('pointermove', window, statePivot.x + 20, statePivot.y + 10, { + ctrlKey: true, + }); + await settle(); + pointer('pointerup', window, statePivot.x + 20, statePivot.y + 10); + await settle(); + const beforeBasePivotFrame = framePoints(); + // 3. 팝업을 닫고 기본 기준점을 옮긴다. 앞의 두 저장은 아직 대기 중이다 + act(() => poseRows()[0].click()); + await settle(); + const basePivot = pivotCenter(); + pointer('pointerdown', pivotHandle(), basePivot.x, basePivot.y); + pointer('pointermove', window, basePivot.x - 30, basePivot.y - 15, { + ctrlKey: true, + }); + await settle(); + const intendedBasePivot = { x: 0.35, y: 0.4 }; + + // 첫 저장 응답이 기본 기준점 드래그 도중 도착하는 실제 순서 + act(() => { + runtime.emitCommittedForCall(0, 1); + runtime.resolveNext(1); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + const releasedBasePivot = pivotCenter(); + pointer('pointerup', window, basePivot.x - 30, basePivot.y - 15); + await settle(); + // 앞선 자세 저장 때문에 기본 기준점 커밋이 아직 슬롯에 못 들어가도 + // pointerup 착지는 그대로 유지되어야 한다 + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect(pivotCenter().x).toBeCloseTo(releasedBasePivot.x, 6); + expect(pivotCenter().y).toBeCloseTo(releasedBasePivot.y, 6); + act(() => { + runtime.emitCommittedForCall(1, 2); + runtime.resolveNext(2); + }); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + act(() => { + runtime.emitCommittedForCall(2, 3); + runtime.resolveNext(3); + }); + await settle(); + + expect(canonicalSprite().pivot).toEqual(intendedBasePivot); + expect(canonicalSprite().poses[0].pivot).toEqual( + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + }), + ); + expect(canonicalSprite().poses[0].transform.x).not.toBe(0); + expect(canonicalSprite().poses[0].transform.y).not.toBe(0); + act(() => poseRows()[0].click()); + await settle(); + const finalFrame = framePoints(); + finalFrame.forEach((point, index) => { + expect(point[0]).toBeCloseTo(beforeBasePivotFrame[index][0], 6); + expect(point[1]).toBeCloseTo(beforeBasePivotFrame[index][1], 6); + }); + expect(finalFrame[0][0]).not.toBeCloseTo(initialFrame[0][0], 6); + expect(finalFrame[0][1]).not.toBeCloseTo(initialFrame[0][1], 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('상태 기준점 연결을 끄고 다시 켜도 이미지가 제자리에 남는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + act(() => poseRows()[0].click()); + await settle(); + const toggle = container.querySelector('[role="switch"]')!; + expect(toggle.getAttribute('aria-checked')).toBe('true'); + const before = framePoints(); + + act(() => toggle.click()); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect( + runtime.commit.mock.calls[0][0].changes?.spritePositions?.['4key']?.[0] + .poses[0].pivot, + ).toEqual({ x: 0.5, y: 0.5 }); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + framePoints().forEach((point, index) => { + expect(point[0]).toBeCloseTo(before[index][0], 6); + expect(point[1]).toBeCloseTo(before[index][1], 6); + }); + + act(() => { + runtime.emitCommittedForCall(0, 1); + runtime.resolveNext(1); + }); + await settle(); + const linkedToggle = + container.querySelector('[role="switch"]')!; + act(() => linkedToggle.click()); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + expect( + runtime.commit.mock.calls[1][0].changes?.spritePositions?.['4key']?.[0] + .poses[0].pivot, + ).toBeNull(); + expect(linkedToggle.getAttribute('aria-checked')).toBe('true'); + framePoints().forEach((point, index) => { + expect(point[0]).toBeCloseTo(before[index][0], 6); + expect(point[1]).toBeCloseTo(before[index][1], 6); + }); + }); + + it('앞선 저장이 큐를 점유해 자세 이동이 draft에만 있을 때 팝업 회전이 이동값을 덮지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 1. 기본 기준점 저장 - 응답을 보류해 직렬 큐를 점유한다 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 140, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 140, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + // 2. 자세를 열고 캔버스에서 이동 - 큐가 막혀 있어 canonical엔 못 들어가고 draft에만 남는다 + act(() => poseRows()[0].click()); + await settle(); + pointer('pointerdown', poseFrame()!, 60, 60); + pointer('pointermove', window, 90, 80); + await settle(); + pointer('pointerup', window, 90, 80); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + expect(canonicalSprite().poses[0].transform.x).toBe(0); + + // 3. 큐가 비기 전에 팝업에서 회전만 편집 + const rotation = container.querySelector( + 'input[aria-label="propertiesPanel.spriteRotation"]', + )!; + act(() => { + rotation.focus(); + setInputValue(rotation, '20'); + }); + act(() => rotation.blur()); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + // 4. 저장을 차례로 풀면 이동 → 회전 순으로 나가고, 회전 wire에도 이동값이 남는다 + runtime.resolveNext(1); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + runtime.resolveNext(2); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + const third = runtime.commit.mock.calls[2][0]; + const wirePose = third.changes?.spritePositions?.['4key']?.[0]?.poses?.[0]; + expect(wirePose?.transform.x).toBeCloseTo(30, 6); + expect(wirePose?.transform.y).toBeCloseTo(20, 6); + expect(wirePose?.transform.rotation).toBe(20); + + runtime.resolveNext(3); + await settle(); + const final = canonicalSprite().poses[0].transform; + expect(final.x).toBeCloseTo(30, 6); + expect(final.y).toBeCloseTo(20, 6); + expect(final.rotation).toBe(20); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('앞선 저장이 큐를 점유해도 기본 기준점 X 뒤 Y 입력이 서로를 덮지 않는다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + + // 큐 점유 - 캔버스 기준점 드래그 저장을 보류 + pointer('pointerdown', pivotHandle(), 100, 75); + pointer('pointermove', window, 120, 75, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, 120, 75); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + const typePercent = (axis: 'X' | 'Y', value: string) => { + const input = container.querySelector( + `input[aria-label="propertiesPanel.spritePivot ${axis}"]`, + )!; + act(() => { + input.focus(); + setInputValue(input, value); + }); + act(() => input.blur()); + }; + typePercent('X', '25'); + await settle(); + typePercent('Y', '80'); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + runtime.resolveNext(1); + await settle(); + runtime.resolveNext(2); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + const third = runtime.commit.mock.calls[2][0]; + const wireSprite = third.changes?.spritePositions?.['4key']?.[0]; + expect(wireSprite?.pivot.x).toBeCloseTo(0.25, 9); + expect(wireSprite?.pivot.y).toBeCloseTo(0.8, 9); + runtime.resolveNext(3); + await settle(); + expect(canonicalSprite().pivot.x).toBeCloseTo(0.25, 9); + expect(canonicalSprite().pivot.y).toBeCloseTo(0.8, 9); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx new file mode 100644 index 000000000..ba337859c --- /dev/null +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.test.tsx @@ -0,0 +1,665 @@ +/** + * 스프라이트 캔버스 핸들 + * - 선택 중엔 기준점 십자를 그리고, 드래그가 9점 스냅·보정 patch를 commit한다 + * - 시작 시점 canonical이 바뀌면(리사이즈 착지·다른 창 편집) 낡은 patch를 버린다 + * - 자세 세션은 본체 이동·회전 노브·모서리 배율을 요소 로컬 px로 계산한다 + * - undo/redo 반영·다른 pointerId·세션 종료는 진행 중 드래그를 커밋 없이 닫는다 + */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + useSpritePoseHandleStore, + type SpritePoseHandleSession, +} from '@stores/grid/useSpritePoseHandleStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; +import { releaseDragSession } from '@hooks/Grid/dragSession'; +import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { + makeCanonicalSpritePosition, + makeSpritePose, +} from '@utils/sprite/spriteFixtures'; + +import SpriteCanvasHandles from './SpriteCanvasHandles'; + +const mocks = vi.hoisted(() => ({ + patchPosition: vi.fn( + (_mode: string, _id: string, _patch: unknown, _gestureId?: string) => + Promise.resolve(undefined), + ), + discardOrphanedLocalPreviews: vi.fn(() => false), + settleCommit: vi.fn(), +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@api/modules/itemsApi', () => ({ + spriteItemsApi: { patchPosition: mocks.patchPosition }, +})); +vi.mock('@src/renderer/editor/runtime/editGestureController', () => ({ + editGestureController: { + discardOrphanedLocalPreviews: mocks.discardOrphanedLocalPreviews, + activeGestureId: () => undefined, + settleCommit: mocks.settleCommit, + }, +})); +vi.mock('@src/renderer/editor/model/elementIdMap', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveElementById: () => ({ mode: '4key' }), +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const SPRITE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + +// 200x100 상자, 원점 (10, 20), 기준점 중앙 → 십자 로컬 (100, 50) +const sprite = () => + makeCanonicalSpritePosition({ + id: SPRITE_ID, + dx: 10, + dy: 20, + width: 200, + height: 100, + pivot: { x: 0.5, y: 0.5 }, + poses: [ + makeSpritePose({ + poseId: 'pose-1', + triggers: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], + transform: { x: 10, y: 0, rotation: 90, scale: 1 }, + }), + ], + }); + +const makeSession = ( + overrides: Partial = {}, +): SpritePoseHandleSession => ({ + positionId: SPRITE_ID, + poseId: 'pose-1', + origin: { dx: 10, dy: 20 }, + width: 200, + height: 100, + pivot: { x: 0.5, y: 0.5 }, + imagePivot: { x: 0.5, y: 0.5 }, + followsBasePivot: true, + placement: { + rect: { x: 0, y: 0, width: 200, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + }, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + preview: vi.fn(), + commit: vi.fn(), + previewPivot: vi.fn(), + commitPivot: vi.fn(), + cancel: vi.fn(), + ...overrides, +}); + +describe('SpriteCanvasHandles', () => { + let container: HTMLDivElement; + let root: Root; + + const pivotHandle = () => + container.querySelector('[data-sprite-pivot-handle="true"]'); + const poseFrame = () => + container.querySelector( + '[data-sprite-pose-frame="true"]', + ); + const rotateKnob = () => + container.querySelector('[data-sprite-rotate-knob="true"]'); + const scaleKnobs = () => + container.querySelectorAll('[data-sprite-scale-knob="true"]'); + + const pointer = ( + type: string, + target: EventTarget, + init: { + clientX?: number; + clientY?: number; + pointerId?: number; + buttons?: number; + shiftKey?: boolean; + ctrlKey?: boolean; + } = {}, + ) => + act(() => { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: init.buttons ?? (type === 'pointerup' ? 0 : 1), + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + pointerId: init.pointerId ?? 1, + shiftKey: init.shiftKey ?? false, + ctrlKey: init.ctrlKey ?? false, + }), + ); + }); + + // move는 rAF 스케줄러에 실리므로 한 틱 기다려 preview를 흘린다 + const flushFrame = async () => { + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + }; + + const render = ( + selected: SelectedElement[] = [{ type: 'sprite', id: SPRITE_ID }], + view: { zoom?: number; panX?: number; panY?: number } = {}, + ) => { + act(() => { + root.render( + , + ); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); + useSpriteStore.setState({ positions: { '4key': [sprite()] } }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + act(() => useSpritePoseHandleStore.getState().setSession(null)); + useSpriteStore.setState({ positions: {} }); + releaseDragSession(); + vi.unstubAllGlobals(); + }); + + it('선택이 없으면 그리지 않고, 스프라이트가 선택되면 기준점 십자를 그린다', () => { + render([]); + expect(pivotHandle()).toBeNull(); + + render(undefined, { zoom: 2, panX: 5, panY: 7 }); + const handle = pivotHandle()!; + // 로컬 (100, 50) → 화면 ((10+100)*2+5, (20+50)*2+7) = (225, 147), 히트 26 중심 + expect(handle.style.left).toBe('212px'); + expect(handle.style.top).toBe('134px'); + expect(poseFrame()).toBeNull(); + }); + + it('스냅 해제 보조키 드래그는 캔버스 메뉴를 열지 않는다', () => { + render(); + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + ctrlKey: true, + }); + + pivotHandle()!.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + it('기준점 조작 전에 포커스된 입력을 정산한다', () => { + render(); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + + expect(document.activeElement).not.toBe(input); + input.remove(); + }); + + it('상자 핸들이 보이는 동안 표식은 선택 테두리 프레임에 앉고 자세 편집 중엔 상자 그대로다', () => { + useSpriteStore.setState({ + positions: { '4key': [{ ...sprite(), pivot: { x: 1, y: 0 } }] }, + }); + render(); + // 로컬 (200, 0) → 화면 (210, 20), 테두리 선 중심은 모서리 밖 1px → (211, 19), 히트 26 중심 + expect(pivotHandle()!.style.left).toBe('198px'); + expect(pivotHandle()!.style.top).toBe('6px'); + + act(() => + useSpritePoseHandleStore + .getState() + .setSession(makeSession({ pivot: { x: 1, y: 0 } })), + ); + render(); + // 자세 프레임 선은 상자 그대로라 보정 없이 (210, 20) + expect(pivotHandle()!.style.left).toBe('197px'); + expect(pivotHandle()!.style.top).toBe('7px'); + }); + + it('기준점 드래그는 그림 프리뷰 없이 표식만 9점에 스냅하고 놓으면 커밋한다', async () => { + render(); + const handle = pivotHandle()!; + pointer('pointerdown', handle, { clientX: 110, clientY: 70 }); + // 왼쪽 아래 모서리 (10, 120)에서 5px 안쪽 - 스냅 반경 8px 안 + pointer('pointermove', window, { clientX: 14, clientY: 117 }); + await flushFrame(); + + expect(parseFloat(handle.style.left) + 13).toBeCloseTo(9, 6); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + expect(mocks.patchPosition).toHaveBeenCalledTimes(1); + const patch = mocks.patchPosition.mock.calls[0][2] as { + pivot: { x: number; y: number }; + poses: Array<{ transform: { x: number; y: number } }>; + }; + expect(patch.pivot).toEqual({ x: 0, y: 1 }); + // 연결 상태는 이동값을 유지해 기본 축 변화량을 그대로 따라간다 + expect(patch.poses[0].transform.x).toBe(10); + expect(patch.poses[0].transform.y).toBe(0); + + expect(mocks.patchPosition.mock.calls[0][2]).toEqual(patch); + expect(mocks.settleCommit).toHaveBeenCalled(); + }); + + it('Ctrl을 누르면 스냅 없이 포인터 자리를 그대로 기준점으로 쓴다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { + clientX: 14, + clientY: 117, + ctrlKey: true, + }); + await flushFrame(); + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + const patch = mocks.patchPosition.mock.calls[0][2] as { + pivot: { x: number; y: number }; + }; + expect(patch.pivot.x).toBeCloseTo(4 / 200, 9); + expect(patch.pivot.y).toBeCloseTo(97 / 100, 9); + }); + + it('스냅된 기준점은 더 넓은 이탈 반경까지 유지해 경계에서 떨리지 않는다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + + pointer('pointermove', window, { clientX: 117, clientY: 70 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo(110, 6); + + pointer('pointermove', window, { clientX: 119, clientY: 70 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo(110, 6); + + pointer('pointermove', window, { clientX: 123, clientY: 70 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeGreaterThan(122); + pointer('pointerup', window, { clientX: 123, clientY: 70 }); + }); + + it('기본 기준점은 pointerup이 빠져도 mouseup으로 마지막 위치를 커밋한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + act(() => { + window.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + }), + ); + }); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + }); + + it('기본 기준점은 캡처 해제로 끝나도 마지막 위치를 커밋한다', async () => { + render(); + const handle = pivotHandle()!; + pointer('pointerdown', handle, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + pointer('lostpointercapture', handle, { pointerId: 1, buttons: 0 }); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + }); + + it('기본 기준점은 눌린 move 뒤 buttons 0 move로 끝나도 커밋한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { + clientX: 130, + clientY: 70, + buttons: 1, + }); + await flushFrame(); + + pointer('pointermove', window, { + clientX: 150, + clientY: 70, + buttons: 0, + }); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + }); + + it('창 포커스를 잃은 기본 기준점 드래그는 저장하지 않고 표시만 복원한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + act(() => window.dispatchEvent(new Event('blur'))); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect(parseFloat(pivotHandle()!.style.left) + 13).toBeCloseTo(110, 6); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('드래그 중 canonical이 바뀌면 낡은 patch를 커밋하지 않고 포기한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 14, clientY: 117 }); + await flushFrame(); + expect(parseFloat(pivotHandle()!.style.left) + 13).not.toBeCloseTo(110, 6); + + // 리사이즈 착지·다른 창 편집 - 스토어 객체가 교체된다 + useSpriteStore.setState({ + positions: { '4key': [{ ...sprite(), width: 400 }] }, + }); + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('드래그 중 요소 원점이 바뀌면 이전 좌표계의 기준점 patch를 버린다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 150, clientY: 70 }); + await flushFrame(); + + useSpriteStore.setState({ + positions: { '4key': [{ ...sprite(), dx: 40, dy: 60 }] }, + }); + pointer('pointerup', window, { clientX: 150, clientY: 70 }); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + it('undo/redo 반영은 진행 중 기준점 드래그를 커밋 없이 취소한다', async () => { + render(); + pointer('pointerdown', pivotHandle()!, { clientX: 110, clientY: 70 }); + pointer('pointermove', window, { clientX: 14, clientY: 117 }); + await flushFrame(); + + act(() => + useCommittedApplyStore.setState((state) => ({ + historyTick: state.historyTick + 1, + })), + ); + pointer('pointerup', window, { clientX: 14, clientY: 117 }); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + }); + + describe('자세 세션', () => { + const openSession = ( + overrides: Partial = {}, + view: { zoom?: number; panX?: number; panY?: number } = {}, + ) => { + const session = makeSession(overrides); + act(() => useSpritePoseHandleStore.getState().setSession(session)); + render(undefined, view); + return session; + }; + + it('프레임·회전 노브·모서리 4개를 그리고 자세 편집 중엔 십자가 자세 축을 따라간다', () => { + openSession({ transform: { x: 30, y: 0, rotation: 0, scale: 1 } }); + expect(poseFrame()).not.toBeNull(); + expect(rotateKnob()).not.toBeNull(); + expect(scaleKnobs().length).toBe(4); + // 십자 = 원점 + P + 이동값 = (10+100+30, 20+50) → 히트 26 좌상단 (127, 57) + expect(pivotHandle()!.style.left).toBe('127px'); + expect(pivotHandle()!.style.top).toBe('57px'); + }); + + it('본체 드래그는 줌을 나눈 로컬 이동량만큼 위치를 preview하고 놓으면 커밋한다', async () => { + const session = openSession({}, { zoom: 2 }); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 140, clientY: 80 }); + await flushFrame(); + expect(session.preview).toHaveBeenLastCalledWith({ + x: 20, + y: -10, + rotation: 0, + scale: 1, + }); + expect(session.commit).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 140, clientY: 80 }); + expect(session.commit).toHaveBeenCalledWith({ + x: 20, + y: -10, + rotation: 0, + scale: 1, + }); + }); + + it('회전 노브는 축 기준 각도 차이를 더하고 Shift는 15° 단위로 스냅한다', async () => { + const session = openSession(); + // 축 화면 (110, 70). 노브를 축 오른쪽(각도 0)에서 아래(각도 90)로 + pointer('pointerdown', rotateKnob()!, { clientX: 210, clientY: 70 }); + pointer('pointermove', window, { clientX: 110, clientY: 170 }); + await flushFrame(); + const rotated = ( + session.preview as ReturnType + ).mock.calls.at(-1)![0]; + expect(rotated.rotation).toBeCloseTo(90, 6); + + pointer('pointermove', window, { + clientX: 105, + clientY: 170, + shiftKey: true, + }); + await flushFrame(); + const snapped = ( + session.preview as ReturnType + ).mock.calls.at(-1)![0]; + expect(snapped.rotation).toBe(90); + expect(session.commit).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 105, clientY: 170 }); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ rotation: 90 }), + ); + }); + + it('모서리 노브는 축까지 거리 비로 배율을 바꾼다', async () => { + const session = openSession(); + // 축 (110, 70)에서 50px 떨어진 곳에서 100px 떨어진 곳으로 - 배율 2 + pointer('pointerdown', scaleKnobs()[0], { clientX: 160, clientY: 70 }); + pointer('pointermove', window, { clientX: 210, clientY: 70 }); + await flushFrame(); + const scaled = ( + session.preview as ReturnType + ).mock.calls.at(-1)![0]; + expect(scaled.scale).toBeCloseTo(2, 9); + expect(session.commit).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 210, clientY: 70 }); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ scale: 2 }), + ); + }); + + it('다른 pointerId의 move·up은 무시되고 세션 종료가 드래그를 취소한다', async () => { + const session = openSession(); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { + clientX: 150, + clientY: 100, + pointerId: 2, + }); + await flushFrame(); + expect(session.preview).not.toHaveBeenCalled(); + pointer('pointerup', window, { + clientX: 150, + clientY: 100, + pointerId: 2, + }); + expect(session.commit).not.toHaveBeenCalled(); + + act(() => useSpritePoseHandleStore.getState().setSession(null)); + expect(session.cancel).toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).not.toHaveBeenCalled(); + }); + + it('캡처 해제의 buttons가 남아 있어도 마지막 자세를 커밋한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + expect(session.preview).toHaveBeenCalled(); + + pointer('lostpointercapture', frame, { pointerId: 1 }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + expect(session.cancel).not.toHaveBeenCalled(); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).toHaveBeenCalledOnce(); + }); + + it('버튼을 놓은 캡처 해제는 누락된 up 대신 마지막 자세를 커밋한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + + pointer('lostpointercapture', frame, { pointerId: 1, buttons: 0 }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + expect(session.cancel).not.toHaveBeenCalled(); + + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).toHaveBeenCalledOnce(); + }); + + it('pointerup이 누락돼도 mouseup으로 마지막 자세를 커밋한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + + act(() => { + window.dispatchEvent( + new MouseEvent('mouseup', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 0, + }), + ); + }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + + pointer('pointermove', window, { + clientX: 180, + clientY: 100, + buttons: 0, + }); + await flushFrame(); + expect(session.preview).toHaveBeenCalledTimes(1); + }); + + it('눌린 move 뒤 buttons 0 move만 오면 누락된 up으로 정산한다', async () => { + const session = openSession(); + const frame = poseFrame()!; + pointer('pointerdown', frame, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { + clientX: 130, + clientY: 100, + buttons: 1, + }); + await flushFrame(); + + pointer('pointermove', window, { + clientX: 150, + clientY: 100, + buttons: 0, + }); + expect(session.commit).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith( + expect.objectContaining({ x: 50, y: 0 }), + ); + + pointer('pointermove', window, { + clientX: 180, + clientY: 100, + buttons: 0, + }); + pointer('pointerup', window, { clientX: 180, clientY: 100 }); + await flushFrame(); + expect(session.preview).toHaveBeenCalledTimes(2); + expect(session.commit).toHaveBeenCalledOnce(); + }); + + it('리사이즈 착지의 소유권 무효화 뒤 up은 커밋 대신 취소한다', async () => { + const session = openSession(); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + pointer('pointermove', window, { clientX: 150, clientY: 100 }); + await flushFrame(); + act(() => + useSpritePoseHandleStore.getState().invalidateOwnership(SPRITE_ID), + ); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + expect(session.commit).not.toHaveBeenCalled(); + expect(session.cancel).toHaveBeenCalled(); + }); + + it('같은 자세라도 요소 원점이 바뀌면 진행 중 드래그의 좌표계 소유권을 무효화한다', () => { + const session = openSession(); + pointer('pointerdown', poseFrame()!, { clientX: 100, clientY: 100 }); + + act(() => + useSpritePoseHandleStore.getState().setSession({ + ...session, + origin: { dx: 40, dy: 60 }, + }), + ); + pointer('pointerup', window, { clientX: 150, clientY: 100 }); + + expect(session.commit).not.toHaveBeenCalled(); + expect(session.cancel).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx b/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx deleted file mode 100644 index c9c39bbcc..000000000 --- a/src/renderer/components/main/Grid/handles/SpritePoseGizmo.test.tsx +++ /dev/null @@ -1,323 +0,0 @@ -/** - * 자세 기즈모 드래그 소유권 - * - 노브 드래그가 축 고정 역산으로 preview·commit을 흘리고 마지막 move를 flush한다 - * - 다른 pointerId의 move·up은 무시된다 - * - 세션 종료가 활성 드래그를 취소하고 전역 드래그 락을 돌려놓는다 - * - undo/redo 반영은 진행 중 드래그를 커밋 없이 취소한다 - */ -import React, { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - useSpritePoseGizmoStore, - type SpritePoseGizmoSession, -} from '@stores/grid/useSpritePoseGizmoStore'; -import { useCommittedApplyStore } from '@stores/data/useCommittedApplyStore'; -import { - releaseDragSession, - tryAcquireDragSession, -} from '@hooks/Grid/dragSession'; - -import SpritePoseGizmo from './SpritePoseGizmo'; - -vi.mock('@contexts/useTranslation', () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } -).IS_REACT_ACT_ENVIRONMENT = true; - -// 축(50,50)·핀(50,200) - 아래로 뻗은 팔, 기본 길이 150px -const makeSession = ( - overrides: Partial = {}, -): SpritePoseGizmoSession => ({ - positionId: 'sprite-1', - poseId: 'pose-1', - origin: { dx: 0, dy: 0 }, - imageRect: { x: 0, y: 0, width: 100, height: 200 }, - pivot: { x: 0.5, y: 0.25 }, - contactPoint: { x: 0.5, y: 1 }, - transform: { x: 0, y: 0, rotation: 0, scale: 1 }, - stretch: false, - preview: vi.fn(), - commit: vi.fn(), - cancel: vi.fn(), - commitContactPoint: vi.fn(), - ...overrides, -}); - -describe('SpritePoseGizmo', () => { - let container: HTMLDivElement; - let root: Root; - - const knob = () => - container.querySelector('[data-sprite-contact-knob="true"]'); - - const pointer = ( - type: string, - target: EventTarget, - init: { - clientX?: number; - clientY?: number; - pointerId?: number; - buttons?: number; - } = {}, - ) => - act(() => { - target.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - cancelable: true, - button: 0, - // 실제 브라우저처럼 누른 상태가 기본이고 up만 0이다 - buttons: init.buttons ?? (type === 'pointerup' ? 0 : 1), - clientX: init.clientX ?? 0, - clientY: init.clientY ?? 0, - pointerId: init.pointerId ?? 1, - }), - ); - }); - - beforeEach(() => { - // rAF 스케줄러를 타이머로 강등 - flush 경로와 분리해 결정적으로 만든다 - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => - window.setTimeout(() => callback(performance.now()), 0), - ); - vi.stubGlobal('cancelAnimationFrame', (id: number) => { - window.clearTimeout(id); - }); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - container.remove(); - act(() => useSpritePoseGizmoStore.getState().setSession(null)); - releaseDragSession(); - vi.unstubAllGlobals(); - }); - - const render = (session: SpritePoseGizmoSession) => { - act(() => useSpritePoseGizmoStore.getState().setSession(session)); - act(() => { - root.render(); - }); - }; - - it('세션이 없으면 그리지 않고, 있으면 핀 위치에 노브를 그린다', () => { - act(() => { - root.render(); - }); - expect(knob()).toBeNull(); - - render(makeSession()); - const el = knob()!; - // 핀 월드 (50,200), 히트 박스 22px 중앙 정렬 - expect(el.style.left).toBe('39px'); - expect(el.style.top).toBe('189px'); - }); - - // 팝업은 body 포털이라 캔버스 노브는 바깥 클릭으로 읽힌다. 이 마커가 없으면 - // 노브를 누르는 순간 자세 팝업이 닫히고 세션이 걷혀 드래그가 즉사한다 - it('루트에 온캔버스 편집 오버레이 마커를 싣는다', () => { - render(makeSession()); - expect( - knob()!.closest('[data-dmn-canvas-editor-overlay="true"]'), - ).not.toBeNull(); - }); - - it('노브 드래그는 up에서 마지막 move를 flush해 커밋한다 - 축 고정 회전 역산', () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); - // rAF 타이머를 돌리지 않아도 up의 flush가 이 좌표를 반영해야 한다 - pointer('pointermove', window, { clientX: 200, clientY: 50 }); - pointer('pointerup', window, { clientX: 200, clientY: 50 }); - - expect(session.commit).toHaveBeenCalledTimes(1); - const committed = (session.commit as ReturnType).mock - .calls[0][0]; - // 아래(90°) → 오른쪽(0°) 목표, 축 고정이라 x·y 불변 - expect(committed.rotation).toBeCloseTo(-90, 4); - expect(committed.scale).toBe(1); - expect(committed.x).toBe(0); - expect(committed.y).toBe(0); - expect(session.cancel).not.toHaveBeenCalled(); - }); - - it('다른 pointerId의 move·up은 활성 드래그에 개입하지 못한다', () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { - clientX: 50, - clientY: 200, - pointerId: 1, - }); - pointer('pointermove', window, { - clientX: 999, - clientY: 999, - pointerId: 2, - }); - pointer('pointerup', window, { clientX: 999, clientY: 999, pointerId: 2 }); - expect(session.commit).not.toHaveBeenCalled(); - - pointer('pointermove', window, { clientX: 200, clientY: 50, pointerId: 1 }); - pointer('pointerup', window, { clientX: 200, clientY: 50, pointerId: 1 }); - expect(session.commit).toHaveBeenCalledTimes(1); - expect( - (session.commit as ReturnType).mock.calls[0][0].rotation, - ).toBeCloseTo(-90, 4); - }); - - it('드래그 중 세션 종료는 제스처를 취소하고 전역 드래그 락을 돌려놓는다', () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); - // 드래그가 락을 점유 중이다 - expect(tryAcquireDragSession()).toBe(false); - - act(() => useSpritePoseGizmoStore.getState().setSession(null)); - - expect(session.cancel).toHaveBeenCalledTimes(1); - expect(session.commit).not.toHaveBeenCalled(); - // 락 반환 - 이후 요소 드래그가 다시 가능해야 한다 - expect(tryAcquireDragSession()).toBe(true); - releaseDragSession(); - - // 유령 up이 와도 커밋되지 않는다 - pointer('pointerup', window, { clientX: 200, clientY: 50 }); - expect(session.commit).not.toHaveBeenCalled(); - }); - - // 리사이즈 착지는 세션을 유지한 채 세대만 올린다. 이때 대기 중이던 마지막 - // move를 flush하면 패널이 이미 닫아둔 preview 제스처가 다시 열리고, 커밋만 - // 생략되어 낡은 세션이 남는다 - it('소유권이 무효화되면 대기 move를 버리고 커밋 없이 취소한다', () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); - pointer('pointermove', window, { clientX: 200, clientY: 50 }); - act(() => - useSpritePoseGizmoStore.getState().invalidateOwnership('sprite-1'), - ); - (session.preview as ReturnType).mockClear(); - - pointer('pointerup', window, { clientX: 200, clientY: 50 }); - - expect(session.preview).not.toHaveBeenCalled(); - expect(session.commit).not.toHaveBeenCalled(); - expect(session.cancel).toHaveBeenCalledTimes(1); - // 락 반환까지 포기 경로와 동일해야 한다 - expect(tryAcquireDragSession()).toBe(true); - releaseDragSession(); - }); - - it('창 밖에서 버튼이 떼진 stale move는 커밋 없이 드래그를 끝낸다', async () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); - pointer('pointermove', window, { clientX: 200, clientY: 50, buttons: 0 }); - - expect(session.cancel).toHaveBeenCalledTimes(1); - expect(session.commit).not.toHaveBeenCalled(); - // 락을 돌려놔야 다음 드래그가 시작된다 - expect(tryAcquireDragSession()).toBe(true); - releaseDragSession(); - - // 끝난 드래그라 뒤늦은 move도 preview를 열지 않는다 - (session.preview as ReturnType).mockClear(); - pointer('pointermove', window, { clientX: 210, clientY: 60 }); - await act(async () => { - await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); - expect(session.preview).not.toHaveBeenCalled(); - }); - - it('소유권 무효화 뒤의 move는 preview를 되열지 않는다', async () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); - act(() => - useSpritePoseGizmoStore.getState().invalidateOwnership('sprite-1'), - ); - - pointer('pointermove', window, { clientX: 200, clientY: 50 }); - // rAF 스케줄러가 타이머로 강등돼 있어 한 틱 뒤에 콜백이 돈다 - await act(async () => { - await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); - expect(session.preview).not.toHaveBeenCalled(); - }); - - // undo는 대상(positionId·poseId)도 세대도 바꾸지 않는다. 시작 시점 transform으로 - // 푼 결과를 up이 그대로 커밋하면 방금 되돌린 저장값이 다시 저장된다 - it('undo/redo 반영은 진행 중 드래그를 커밋 없이 취소한다', () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); - pointer('pointermove', window, { clientX: 200, clientY: 50 }); - act(() => useCommittedApplyStore.getState().bump('historyUndo')); - - expect(session.cancel).toHaveBeenCalledTimes(1); - expect(tryAcquireDragSession()).toBe(true); - releaseDragSession(); - - pointer('pointerup', window, { clientX: 200, clientY: 50 }); - expect(session.commit).not.toHaveBeenCalled(); - }); - - it('일반 커밋 echo는 진행 중 드래그를 끊지 않는다', () => { - const session = makeSession(); - render(session); - - pointer('pointerdown', knob()!, { clientX: 50, clientY: 200 }); - pointer('pointermove', window, { clientX: 200, clientY: 50 }); - act(() => useCommittedApplyStore.getState().bump('local')); - - expect(session.cancel).not.toHaveBeenCalled(); - pointer('pointerup', window, { clientX: 200, clientY: 50 }); - expect(session.commit).toHaveBeenCalledTimes(1); - }); - - it('Alt 드래그는 transform 대신 핀 위치를 커밋한다', () => { - const session = makeSession(); - render(session); - - act(() => { - knob()!.dispatchEvent( - new PointerEvent('pointerdown', { - bubbles: true, - cancelable: true, - button: 0, - clientX: 50, - clientY: 200, - pointerId: 1, - altKey: true, - }), - ); - }); - pointer('pointermove', window, { clientX: 50, clientY: 100 }); - pointer('pointerup', window, { clientX: 50, clientY: 100 }); - - expect(session.commit).not.toHaveBeenCalled(); - expect(session.commitContactPoint).toHaveBeenCalledTimes(1); - const pin = (session.commitContactPoint as ReturnType).mock - .calls[0][0]; - // (50,100) → 이미지 정규화 (0.5, 0.5) - expect(pin.x).toBeCloseTo(0.5, 4); - expect(pin.y).toBeCloseTo(0.5, 4); - }); -}); diff --git a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx index 38d7071b3..584f9c408 100644 --- a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx @@ -24,8 +24,6 @@ const position = ( className: null, useInlineStyles: null, baseImage: 'hand.png', - imageFit: 'contain', - imageRect: { x: 10, y: 20, width: 100, height: 80 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [], @@ -33,7 +31,6 @@ const position = ( pressDurationMs: 300, transitionMs: 90, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, ...overrides, } as ReactiveSpritePosition); @@ -87,10 +84,12 @@ describe('SpriteDuplicateGhost', () => { expect(guide?.style.borderColor).toBe(ACTIVITY_AREA_GUIDE_COLOR); }); - it('이미지 원점은 활동 영역 좌상단 기준이다 (아이템과 같은 규칙)', () => { + it('이미지는 활동 영역 상자를 그대로 채운다 (아이템과 같은 규칙)', () => { const image = renderGhost()?.querySelector('img'); - expect(image?.style.left).toBe('10px'); - expect(image?.style.top).toBe('20px'); + expect(image?.style.left).toBe('0px'); + expect(image?.style.top).toBe('0px'); + expect(image?.style.width).toBe('200px'); + expect(image?.style.height).toBe('120px'); }); it('이미지 로드 실패는 깨진 img 대신 아이템과 같은 자리표시자를 그린다', () => { diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index c7b795da2..ab1e8653d 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -49,7 +49,6 @@ const spritePosition = ( dy: 20, layerName: null, groupId: null, - imageFit: 'contain', ...overrides, }); @@ -76,8 +75,8 @@ const renderSprite = ( return container.querySelector('[data-sprite-element="true"]'); }; -const activityGuide = () => - container.querySelector('[data-sprite-activity-guide="true"]'); +const idleGhost = () => + container.querySelector('[data-sprite-idle-ghost="true"]'); describe('SpriteItem 렌더', () => { beforeEach(() => { @@ -92,33 +91,21 @@ describe('SpriteItem 렌더', () => { useSpriteEditPreviewStore.setState({ preview: null }); }); - it('활동 영역 가이드는 이미지 유무에 따라 상시·호버로 갈린다', () => { - // 이미지 없는 스프라이트는 가이드가 유일한 실체 - 점선 상시 표시 + it('이미지가 없으면 자리표시자만 그리고 선택 표식은 공통 아웃라인이 맡는다', () => { const idle = renderSprite(spritePosition()); expect(idle).not.toBeNull(); - expect(activityGuide()?.className).toContain('border-dashed'); - expect(activityGuide()?.className).not.toContain('border-transparent'); expect(idle?.dataset.selected).toBeUndefined(); - // 이미지가 없으면 자리표시자 렌더 expect( idle?.querySelector('[data-sprite-placeholder="true"]'), ).not.toBeNull(); + // 상자가 곧 이미지라 별도 가이드·표식이 없다 - 핸들은 오버레이 층 몫 + expect( + idle?.querySelector('[data-sprite-activity-guide="true"]'), + ).toBeNull(); + expect(idle?.querySelector('[data-sprite-pivot-marker="true"]')).toBeNull(); - // 이미지가 있으면 점선은 소음이라 기본 투명, 호버에만 드러난다. - // 호버는 캡처를 받는 루트(group) 기준이라 눌린 동안에도 유지된다 - renderSprite(spritePosition({ baseImage: 'hand.png' })); - expect(activityGuide()?.className).toContain('border-transparent'); - // 호버 색도 고스트와 같은 토큰 - expect(activityGuide()?.className).toContain( - 'group-hover/sprite:border-[color:var(--ui-guide-activity)]', - ); - }); - - it('선택 상태는 공통 아웃라인이 전담해 내부 가이드를 그리지 않는다', () => { const selected = renderSprite(spritePosition(), { isSelected: true }); expect(selected?.dataset.selected).toBe('true'); - // 그리드 아웃라인과 겹쳐 이중선이 되던 자리 - 아예 그리지 않는다 - expect(activityGuide()).toBeNull(); }); it('이미지 호스트는 보더가 없어 오버레이와 같은 원점을 쓴다', () => { @@ -128,10 +115,24 @@ describe('SpriteItem 렌더', () => { expect(host?.className).not.toContain('border'); }); - it('가이드 굵기와 radius는 줌에 반비례해 화면 px로 고정된다', () => { - renderSprite(spritePosition(), { zoom: 4 }); - expect(activityGuide()?.style.borderWidth).toBe('0.25px'); - expect(activityGuide()?.style.borderRadius).toBe('1px'); + it('기본 이미지는 요소 상자를 그대로 채운다', () => { + const host = renderSprite( + spritePosition({ + baseImage: 'hand.png', + width: 120, + height: 80, + pivot: { x: 0.25, y: 1 }, + referenceNaturalSize: { source: 'hand.png', width: 60, height: 40 }, + }), + ); + const img = host?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + expect(img?.style.left).toBe('0px'); + expect(img?.style.top).toBe('0px'); + expect(img?.style.width).toBe('120px'); + expect(img?.style.height).toBe('80px'); + expect(img?.style.transformOrigin).toBe('25% 100%'); }); it('hidden 스프라이트는 렌더하지 않는다', () => { @@ -148,13 +149,12 @@ describe('SpriteItem 렌더', () => { }), ); - const img = node?.querySelector('img'); + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); // 인라인 선언이 비어야 사용자 CSS가 !important 없이 이긴다 expect(img?.style.transform).toBe(''); expect(img?.style.objectFit).toBe(''); - expect(img?.style.getPropertyValue('--dmn-sprite-fit-default')).toBe( - 'contain', - ); expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( 'translate(4px, -2px) rotate(30deg) scale(1.5)', ); @@ -176,11 +176,14 @@ describe('SpriteItem 렌더', () => { }), ); - const img = node?.querySelector('img'); + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); expect(img?.style.transform).toBe( 'translate(4px, -2px) rotate(30deg) scale(1.5)', ); - expect(img?.style.objectFit).toBe('contain'); + // 상자가 비트맵 비율이라 늘리기가 곧 맞춤 - 인라인 모드는 fill로 승격 + expect(img?.style.objectFit).toBe('fill'); expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( '', ); @@ -192,8 +195,6 @@ describe('SpriteItem 자세 편집 프리뷰', () => { poseId: string, overrides: Partial = {}, ): SpritePose => ({ - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId, triggers: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], @@ -238,51 +239,104 @@ describe('SpriteItem 자세 편집 프리뷰', () => { }), ); - const img = node?.querySelector('img'); + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); expect(img?.style.transform).toBe( 'translate(10px, 5px) rotate(45deg) scale(2)', ); expect(img?.getAttribute('src')).toContain('override'); }); - it('이미지 설정 발행은 기준점 마커를 그리고 회수하면 사라진다', () => { + it('자세 편집 중에는 기본 자세를 반투명 고스트로 뒤에 남긴다', () => { act(() => - useSpriteEditPreviewStore - .getState() - .publish({ kind: 'pivot', positionId: SPRITE_ID }), + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1'), + preferFallback: false, + }), ); const node = renderSprite( spritePosition({ - pivot: { x: 0.25, y: 1 }, - imageRect: { x: 10, y: 20, width: 100, height: 80 }, + baseImage: 'data:image/png;base64,base', + useInlineStyles: true, + poses: [pose('pose-1')], }), ); - const marker = node?.querySelector( - '[data-sprite-pivot-marker="true"]', + const ghost = idleGhost(); + expect(ghost).not.toBeNull(); + expect(ghost?.getAttribute('src')).toContain('base'); + expect(ghost?.style.opacity).toBe('0.3'); + // 고스트는 기본 자세 그대로, 실제 이미지는 자세 변환 + expect(ghost?.style.transform).toBe( + 'translate(0px, 0px) rotate(0deg) scale(1)', ); - expect(marker).not.toBeNull(); - expect(marker?.style.left).toBe('35px'); - expect(marker?.style.top).toBe('100px'); - expect(marker?.style.transform).toBe('translate(-50%, -50%) scale(1)'); + expect(node?.querySelectorAll('img').length).toBe(2); + // 발행을 거두면 고스트도 사라진다 act(() => useSpriteEditPreviewStore.getState().clear()); - expect(node?.querySelector('[data-sprite-pivot-marker="true"]')).toBeNull(); + expect(idleGhost()).toBeNull(); }); - it('기준점 마커도 줌 보정으로 화면 크기를 유지한다', () => { + it('기본 자세와 같은 그림이면 고스트를 겹쳐 그리지 않는다', () => { act(() => - useSpriteEditPreviewStore - .getState() - .publish({ kind: 'pivot', positionId: SPRITE_ID }), + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + }), + preferFallback: true, + }), ); - const node = renderSprite(spritePosition(), { zoom: 4 }); + renderSprite(spritePosition({ baseImage: 'data:image/png;base64,base' })); + expect(idleGhost()).toBeNull(); + }); - const marker = node?.querySelector( - '[data-sprite-pivot-marker="true"]', + it('크기가 다른 자세 이미지는 같은 기준점을 축에 맞춰 원본 비율로 그린다', () => { + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: 'pose-1', + fallbackPose: pose('pose-1', { + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + imageOverride: 'data:image/png;base64,override', + imageOverrideMetrics: { + source: 'data:image/png;base64,override', + width: 50, + height: 200, + }, + }), + preferFallback: true, + }), ); - // 중심은 축 위치에 남고 크기만 줄어든다 - expect(marker?.style.transform).toBe('translate(-50%, -50%) scale(0.25)'); + const node = renderSprite( + spritePosition({ + baseImage: 'data:image/png;base64,base', + width: 200, + height: 200, + pivot: { x: 0.5, y: 1 }, + referenceNaturalSize: { + source: 'data:image/png;base64,base', + width: 100, + height: 100, + }, + }), + ); + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); + // 배율 2 → 100x400, 기준점(0.5, 1)이 P(100, 200)에 놓인다 → left 50, top -200 + expect(img?.getAttribute('src')).toContain('override'); + expect((img as HTMLElement | null)?.style.width).toBe('100px'); + expect((img as HTMLElement | null)?.style.height).toBe('400px'); + expect((img as HTMLElement | null)?.style.left).toBe('50px'); + expect((img as HTMLElement | null)?.style.top).toBe('-200px'); }); it('무효 draft 편집은 canonical 값 대신 스냅샷을 우선한다', () => { @@ -306,7 +360,9 @@ describe('SpriteItem 자세 편집 프리뷰', () => { }), ); - const img = node?.querySelector('img'); + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); expect(img?.style.transform).toBe( 'translate(3px, 4px) rotate(0deg) scale(1)', ); @@ -333,7 +389,9 @@ describe('SpriteItem 자세 편집 프리뷰', () => { }), ); - const img = node?.querySelector('img'); + const img = node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); expect(img?.style.transform).toBe( 'translate(7px, 0px) rotate(0deg) scale(1)', ); @@ -357,7 +415,10 @@ describe('SpriteItem 자세 편집 프리뷰', () => { idleTransform: { x: 1, y: 2, rotation: 0, scale: 1 }, }), ); - const img = () => node?.querySelector('img'); + const img = () => + node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); expect(img()?.style.transform).toBe( 'translate(1px, 2px) rotate(0deg) scale(1)', ); @@ -390,7 +451,10 @@ describe('SpriteItem 자세 편집 프리뷰', () => { poses: [pose('pose-1', { imageOverride: '/override.png' })], }), ); - const img = () => node?.querySelector('img'); + const img = () => + node?.querySelector( + 'img:not([data-sprite-idle-ghost])', + ); act(() => useSpriteEditPreviewStore.getState().publish({ @@ -416,7 +480,6 @@ describe('SpriteItem 자세 편집 프리뷰', () => { expect( node?.querySelector('[data-sprite-placeholder="true"]'), ).not.toBeNull(); - expect(activityGuide()?.className).not.toContain('border-transparent'); }); // 스키마가 빈 문자열을 막지 않아 플러그인·임포트로 들어온다. @@ -439,7 +502,11 @@ describe('SpriteItem 자세 편집 프리뷰', () => { }), ); - expect(node?.querySelector('img')?.getAttribute('src')).toBe('/base.png'); + expect( + node + ?.querySelector('img:not([data-sprite-idle-ghost])') + ?.getAttribute('src'), + ).toBe('/base.png'); expect(node?.querySelector('[data-sprite-placeholder="true"]')).toBeNull(); }); }); diff --git a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx index f155b32b9..cf0c27fa8 100644 --- a/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx +++ b/src/renderer/components/overlay/counters/overlaySpriteItem.test.tsx @@ -52,7 +52,6 @@ const makeSprite = ( layerName: null, groupId: null, baseImage: BASE_IMAGE, - imageRect: { x: 10, y: 20, width: 120, height: 80 }, pivot: { x: 0.5, y: 1 }, poses: [makePose('p1', ['el-a'])], ...overrides, @@ -145,16 +144,13 @@ describe('OverlaySpriteItem', () => { }); it('useInlineStyles=false는 외관 채널을 인라인 선언 없이 변수로만 싣는다', () => { - render(makeSprite({ useInlineStyles: false, imageFit: 'cover' })); + render(makeSprite({ useInlineStyles: false })); const img = imgEl(); // 인라인 선언이 비어야 사용자 CSS가 !important 없이 이긴다 expect(img?.style.transform).toBe(''); expect(img?.style.transition).toBe(''); expect(img?.style.objectFit).toBe(''); - expect(img?.style.getPropertyValue('--dmn-sprite-fit-default')).toBe( - 'cover', - ); expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( 'translate(0px, 0px) rotate(0deg) scale(1)', ); @@ -162,7 +158,7 @@ describe('OverlaySpriteItem', () => { 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', ); // 배치·기준점은 모드와 무관하게 인라인 - expect(img?.style.left).toBe('10px'); + expect(img?.style.width).toBe('300px'); expect(img?.style.transformOrigin).toBe('50% 100%'); }); @@ -210,7 +206,7 @@ describe('OverlaySpriteItem', () => { }); it('useInlineStyles=true는 외관 채널을 인라인 선언으로 승격한다', () => { - render(makeSprite({ useInlineStyles: true, imageFit: 'cover' })); + render(makeSprite({ useInlineStyles: true })); const img = imgEl(); expect(img?.style.transform).toBe( @@ -219,7 +215,7 @@ describe('OverlaySpriteItem', () => { expect(img?.style.transition).toBe( 'transform 90ms cubic-bezier(0.4, 0, 0.2, 1)', ); - expect(img?.style.objectFit).toBe('cover'); + expect(img?.style.objectFit).toBe('fill'); expect(img?.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( '', ); @@ -554,13 +550,34 @@ describe('OverlaySpriteItem onPress', () => { // 상자가 바뀌면 React는 idle 스타일 차이만 다시 쓴다 - 직접 쓴 자세 배치가 남지 않게 // 재생을 취소하고 한 벌로 복원한다 + render({ ...sprite, width: sprite.width + 10 }); + expect(animations[0].cancelled).toBe(true); + expect(img.src).not.toContain(OVERRIDE_IMAGE); + expect(img.style.width).toBe(`${sprite.width + 10}px`); + }); + + it('재생 중 자세 기준점·이동값이 바뀌면 진행 중 재생을 끊고 기본 이미지로 되돌린다', () => { + const sprite = oneShotSprite(); + render(sprite); + const img = imgEl()!; + act(() => applyEventKeyState('KeyA', true)); + expect(img.src).toContain(OVERRIDE_IMAGE); + + // 이미지·상자는 그대로, 자세 기준점과 보정 이동값만 바뀐 문서 - 재생이 직접 쓴 + // 축·transform이 낡으므로 상자 변경과 같은 규칙으로 재생을 끊는다 + const pose = sprite.poses[0]; render({ ...sprite, - imageRect: { ...sprite.imageRect, width: sprite.imageRect.width + 10 }, + poses: [ + { + ...pose, + pivot: { x: 0.25, y: 0.75 }, + transform: { ...pose.transform, x: pose.transform.x + 12 }, + }, + ], }); expect(animations[0].cancelled).toBe(true); expect(img.src).not.toContain(OVERRIDE_IMAGE); - expect(img.style.width).toBe(`${sprite.imageRect.width + 10}px`); }); it('트리거의 canonical이 실제로 바뀌면 새 키로 재구독한다', () => { diff --git a/src/renderer/editor/runtime/editorCoordinator.test.ts b/src/renderer/editor/runtime/editorCoordinator.test.ts index 8baf171f1..9490e46c3 100644 --- a/src/renderer/editor/runtime/editorCoordinator.test.ts +++ b/src/renderer/editor/runtime/editorCoordinator.test.ts @@ -3285,8 +3285,6 @@ describe('commitSemanticOpsInternal', () => { className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 40, y: -10, width: 160, height: 80 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, poses: [ @@ -3295,8 +3293,6 @@ describe('commitSemanticOpsInternal', () => { triggers: [DEFAULT_KEY_ID], transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }, ], @@ -3304,7 +3300,6 @@ describe('commitSemanticOpsInternal', () => { pressDurationMs: 300, transitionMs: 90, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, } as never, ], @@ -3331,7 +3326,6 @@ describe('commitSemanticOpsInternal', () => { // sx=2, sy=0.5 const sprite = applied.document.spritePositions['4key'][0]; expect(sprite).toMatchObject({ dx: 5, dy: 8, width: 400, height: 50 }); - expect(sprite.imageRect).toEqual({ x: 80, y: -5, width: 320, height: 40 }); expect(sprite.idleTransform).toEqual({ x: 24, y: -3, @@ -3387,7 +3381,6 @@ describe('commitSemanticOpsInternal', () => { rotation: -90, scale: 0.5, }); - expect(sprite.imageRect).toEqual({ x: 80, y: -5, width: 320, height: 40 }); // canonical과 낙관 로컬이 같은 결과로 수렴 expect( harness.transport.canonical.document.spritePositions['4key'][0], diff --git a/src/renderer/editor/runtime/elementOps.test.ts b/src/renderer/editor/runtime/elementOps.test.ts index d26a3c338..8dc5e93af 100644 --- a/src/renderer/editor/runtime/elementOps.test.ts +++ b/src/renderer/editor/runtime/elementOps.test.ts @@ -152,14 +152,11 @@ const spriteAt = (id: string): ReactiveSpritePosition & { id: string } => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 100, height: 100 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [], transitionMs: 90, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, }); @@ -450,8 +447,6 @@ describe('elementOps', () => { ...spriteAt(crypto.randomUUID()), poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-src-1', triggers: [ID_A], @@ -459,8 +454,6 @@ describe('elementOps', () => { imageOverride: 'sprites/override.png', }, { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-src-2', triggers: [ID_A, ID_B], @@ -782,7 +775,6 @@ describe('elementOps', () => { dy: 10, width: 200, height: 120, - imageRect: { x: 4, y: 8, width: 96, height: 64 }, idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, }; useKeyStore.setState({ @@ -823,7 +815,6 @@ describe('elementOps', () => { // eager: sx=2, sy=1로 스프라이트 콘텐츠 동반 스케일 const eager = useSpriteStore.getState().positions['4key'][0]; expect(eager).toMatchObject({ width: 400, height: 120 }); - expect(eager.imageRect).toEqual({ x: 8, y: 8, width: 192, height: 64 }); expect(eager.idleTransform).toEqual({ x: 24, y: -6, @@ -1485,7 +1476,6 @@ describe('elementOps', () => { it('단일 sprite bounds는 resizeSprite op으로 콘텐츠까지 eager 스케일한다', async () => { const sprite = { ...spriteAt(ID_B), - imageRect: { x: 4, y: 8, width: 96, height: 64 }, idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, poses: [ { @@ -1493,8 +1483,6 @@ describe('elementOps', () => { triggers: [ID_A], transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }, ], @@ -1523,7 +1511,6 @@ describe('elementOps', () => { // eager: 200x120 → 400x60 (sx=2, sy=0.5) const eager = useSpriteStore.getState().positions['4key'][0]; expect(eager).toMatchObject({ dx: 5, dy: 8, width: 400, height: 60 }); - expect(eager.imageRect).toEqual({ x: 8, y: 4, width: 192, height: 32 }); expect(eager.idleTransform).toEqual({ x: 24, y: -3, @@ -1537,13 +1524,11 @@ describe('elementOps', () => { scale: 0.5, }); expect(eager.pivot).toBe(sprite.pivot); - expect(eager.poses[0].contactPoint).toEqual({ x: 0.5, y: 1 }); }); it('sprite bounds의 편입 전 실패는 콘텐츠 필드까지 CAS 복원한다', async () => { const sprite = { ...spriteAt(ID_B), - imageRect: { x: 4, y: 8, width: 96, height: 64 }, idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, poses: [ { @@ -1551,8 +1536,6 @@ describe('elementOps', () => { triggers: [ID_A], transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }, ], @@ -1571,7 +1554,6 @@ describe('elementOps', () => { const restored = useSpriteStore.getState().positions['4key'][0]; expect(restored).toMatchObject({ dx: 0, dy: 0, width: 200, height: 120 }); - expect(restored.imageRect).toEqual(sprite.imageRect); expect(restored.idleTransform).toEqual(sprite.idleTransform); expect(restored.poses).toEqual(sprite.poses); }); diff --git a/src/renderer/editor/runtime/mixedBatchGeometry.test.ts b/src/renderer/editor/runtime/mixedBatchGeometry.test.ts index f822f5e0a..88c777ec9 100644 --- a/src/renderer/editor/runtime/mixedBatchGeometry.test.ts +++ b/src/renderer/editor/runtime/mixedBatchGeometry.test.ts @@ -333,7 +333,7 @@ describe('commitMixedBatchGeometry', () => { dy: 0, width: 80, height: 40, - imageRect: { x: 10, y: 4, width: 40, height: 20 }, + idleTransform: { x: 10, y: 4, rotation: 0, scale: 1 }, }), }); @@ -364,7 +364,7 @@ describe('commitMixedBatchGeometry', () => { const eager = useSpriteStore.getState().positions['4key'][0]; expect(eager).toMatchObject({ dx: 0, dy: 0, width: 80, height: 40 }); // 이동만이라 배율 1 - 콘텐츠는 그대로다 - expect(eager.imageRect).toEqual({ x: 10, y: 4, width: 40, height: 20 }); + expect(eager.idleTransform).toEqual({ x: 10, y: 4, rotation: 0, scale: 1 }); }); it('desired projection은 plugin position만 바꾸고 다른 영속 필드는 보존한다', async () => { diff --git a/src/renderer/editor/runtime/mixedElementGroups.test.ts b/src/renderer/editor/runtime/mixedElementGroups.test.ts index dd84f3071..939806ec5 100644 --- a/src/renderer/editor/runtime/mixedElementGroups.test.ts +++ b/src/renderer/editor/runtime/mixedElementGroups.test.ts @@ -527,14 +527,11 @@ describe('setMixedLayerGroupHidden', () => { className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 100, height: 100 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [], transitionMs: 90, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, }, ], diff --git a/src/renderer/editor/runtime/previewOverlay.test.ts b/src/renderer/editor/runtime/previewOverlay.test.ts new file mode 100644 index 000000000..fe5893e2e --- /dev/null +++ b/src/renderer/editor/runtime/previewOverlay.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { previewOverlay, composePreviewPositions } from './previewOverlay'; + +const canonical = { + '4key': [{ id: 'sprite-a', value: 0, remote: 0 }], +}; + +describe('previewOverlay 로컬 세션 수명', () => { + beforeEach(() => previewOverlay.clearAll()); + + it('새 로컬 세션은 끝나지 않고 남은 이전 로컬 세션을 교체한다', () => { + previewOverlay.applyLocalPatchByIds( + '11111111-1111-4111-8111-111111111111', + '4key', + ['sprite-a'], + { value: 10 }, + 'spritePosition', + ); + previewOverlay.applyRemoteEnvelope({ + schemaVersion: 1, + sessionId: '22222222-2222-4222-8222-222222222222', + seq: 1, + kind: 'patch', + sourceLabel: 'panel', + domain: 'spritePosition', + mode: '4key', + targets: [0], + patch: { remote: 7 }, + }); + previewOverlay.applyLocalPatchByIds( + '33333333-3333-4333-8333-333333333333', + '4key', + ['sprite-a'], + { value: 20 }, + 'spritePosition', + ); + + expect( + composePreviewPositions('spritePosition', canonical)['4key'][0], + ).toEqual({ id: 'sprite-a', value: 20, remote: 7 }); + + previewOverlay.endSession('33333333-3333-4333-8333-333333333333'); + expect( + composePreviewPositions('spritePosition', canonical)['4key'][0], + ).toEqual({ id: 'sprite-a', value: 0, remote: 7 }); + }); + + it('소유권 없는 로컬 세션만 회수하고 원격 세션은 유지한다', () => { + const localSessionId = '11111111-1111-4111-8111-111111111111'; + previewOverlay.applyLocalPatchByIds( + localSessionId, + '4key', + ['sprite-a'], + { value: 10 }, + 'spritePosition', + ); + previewOverlay.applyRemoteEnvelope({ + schemaVersion: 1, + sessionId: '22222222-2222-4222-8222-222222222222', + seq: 1, + kind: 'patch', + sourceLabel: 'panel', + domain: 'spritePosition', + mode: '4key', + targets: [0], + patch: { remote: 7 }, + }); + + expect(previewOverlay.discardLocalSessionsExcept(null)).toEqual([ + localSessionId, + ]); + expect( + composePreviewPositions('spritePosition', canonical)['4key'][0], + ).toEqual({ id: 'sprite-a', value: 0, remote: 7 }); + }); +}); diff --git a/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts b/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts index c17e88a9d..f4d01b4b6 100644 --- a/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts +++ b/src/renderer/editor/runtime/spritePatchOptimisticIntegration.test.ts @@ -88,14 +88,11 @@ const spriteFixture = (): CanonicalReactiveSpritePosition => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 100, height: 100 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [], transitionMs: 90, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', - imagePlacement: 'box', referenceNaturalSize: null, }); @@ -197,8 +194,6 @@ describe('스프라이트 필드 패치 낙관 적용 통합', () => { triggers: [B_ID, A_ID], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }; diff --git a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx index ec5394d3c..0a6818b3f 100644 --- a/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx +++ b/src/renderer/hooks/Grid/useGridCanvasActions.test.tsx @@ -98,14 +98,11 @@ const sprite = (id: string): ReactiveSpritePosition & { id: string } => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: 'contain', - imageRect: { x: 0, y: 0, width: 200, height: 200 }, pivot: { ...CENTER_SPRITE_ANCHOR }, idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, poses: [], transitionMs: DEFAULT_SPRITE_TRANSITION_MS, transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, - imagePlacement: 'box', referenceNaturalSize: null, }); @@ -213,14 +210,11 @@ describe('useGridCanvasActions create와 ghost duplicate', () => { className: null, useInlineStyles: null, baseImage: null, - imageFit: 'fill', - imageRect: { x: 0, y: 0, width: 200, height: 200 }, pivot: CENTER_SPRITE_ANCHOR, idleTransform: IDENTITY_SPRITE_TRANSFORM, poses: [], transitionMs: DEFAULT_SPRITE_TRANSITION_MS, transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, - imagePlacement: 'pivot', referenceNaturalSize: null, }); // wire 정규화 계약: nullish layerName·groupId는 키 부재 diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index 93619ff4b..943424073 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -196,14 +196,10 @@ const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 4, y: 8, width: 96, height: 64 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [STABLE_A], @@ -213,7 +209,6 @@ const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ ], transitionMs: 90, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, }); @@ -481,10 +476,9 @@ describe('useGridResize plugin gesture lifecycle', () => { expect.anything(), ); // eager: 200x120 → 100x60 (sx=sy=0.5)로 콘텐츠까지 비례 스케일. - // pivot·contactPoint는 정규화 좌표라 불변 (pivot은 참조까지 보존) + // pivot은 정규화 좌표라 불변 (참조까지 보존) const eager = useSpriteStore.getState().positions['4key'][0]; expect(eager).toMatchObject({ dx: 10, dy: 20, width: 100, height: 60 }); - expect(eager.imageRect).toEqual({ x: 2, y: 4, width: 48, height: 32 }); expect(eager.idleTransform).toEqual({ x: 0, y: 0, rotation: 0, scale: 1 }); expect(eager.poses[0].transform).toEqual({ x: 6, @@ -493,7 +487,6 @@ describe('useGridResize plugin gesture lifecycle', () => { scale: 1.2, }); expect(eager.pivot).toBe(original.pivot); - expect(eager.poses[0].contactPoint).toEqual({ x: 0.5, y: 1 }); }); it('혼합 그룹의 native ID가 비정규면 plugin까지 함께 fail-close한다', async () => { diff --git a/src/renderer/hooks/Grid/useGridSelection.test.tsx b/src/renderer/hooks/Grid/useGridSelection.test.tsx index e48fecae2..0270e85d3 100644 --- a/src/renderer/hooks/Grid/useGridSelection.test.tsx +++ b/src/renderer/hooks/Grid/useGridSelection.test.tsx @@ -144,14 +144,10 @@ const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 4, y: 8, width: 96, height: 64 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', triggers: [STABLE_KEY_ID], @@ -161,7 +157,6 @@ const spriteAt = (id: string): CanonicalReactiveSpritePosition => ({ ], transitionMs: 90, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, }); const keyPosition = { @@ -504,7 +499,6 @@ describe('useGridSelection compound history gesture', () => { expect(pasted.dy).toBe(original.dy + PASTE_OFFSET); expect(pasted.width).toBe(original.width); expect(pasted.height).toBe(original.height); - expect(pasted.imageRect).toEqual(original.imageRect); expect(pasted.pivot).toEqual(original.pivot); // 사본 poseId는 재발급 - 원본과 공유하면 백엔드 커밋이 중복으로 거부. // 트리거·변환·이미지는 원본과 동일 @@ -546,7 +540,6 @@ describe('useGridSelection compound history gesture', () => { )!; expect(spriteElement.position.id).toBe(pasted.id); expect(spriteElement.position.dx).toBe(original.dx + PASTE_OFFSET); - expect(spriteElement.position.imageRect).toEqual(original.imageRect); // wire payload도 eager 사본과 같은 재발급 poseId를 싣는다 expect(spriteElement.position.poses).toEqual(pasted.poses); }); @@ -561,8 +554,6 @@ describe('useGridSelection compound history gesture', () => { ...spriteAt(STABLE_SPRITE_ID), poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'pose-1', // 배치 안 키와 배치 밖 키 참조 혼합 diff --git a/src/renderer/hooks/shared/useLayoutComputation.test.ts b/src/renderer/hooks/shared/useLayoutComputation.test.ts index 34195ca8c..ea9e534f1 100644 --- a/src/renderer/hooks/shared/useLayoutComputation.test.ts +++ b/src/renderer/hooks/shared/useLayoutComputation.test.ts @@ -34,14 +34,11 @@ const makeSprite = ( className: null, useInlineStyles: null, baseImage: 'base.png', - imageFit: null, - imageRect: { x: 0, y: 0, width: 200, height: 200 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: makeTransform(), poses: [], transitionMs: 90, transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, - imagePlacement: 'box', referenceNaturalSize: null, ...overrides, }); @@ -165,8 +162,6 @@ describe('computeLayout 도달 범위 열거 게이트', () => { id: 'co', poses: [ { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'a', triggers: ['k1'], @@ -174,8 +169,6 @@ describe('computeLayout 도달 범위 열거 게이트', () => { imageOverride: null, }, { - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: 'b', triggers: ['k2'], @@ -190,8 +183,6 @@ describe('computeLayout 도달 범위 열거 게이트', () => { makeSprite({ id: 'over-cap', poses: Array.from({ length: 11 }, (_, index) => ({ - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: `cap-${index}`, triggers: [`c${index}`], @@ -238,8 +229,6 @@ describe('computeLayout 도달 범위 열거 게이트', () => { makeSprite({ id, poses: Array.from({ length: 10 }, (_, index) => ({ - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId: `${id}-${index}`, triggers: [`c${index}`], diff --git a/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts index d8c83d666..bffb458cf 100644 --- a/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts +++ b/src/renderer/stores/grid/useSpriteEditPreviewStore.test.ts @@ -30,23 +30,14 @@ describe('useSpriteEditPreviewStore', () => { useSpriteEditPreviewStore.getState().publish({ ...payload }); expect(listener).toHaveBeenCalledTimes(1); - useSpriteEditPreviewStore - .getState() - .publish({ kind: 'pivot', positionId: 'sprite-1' }); - expect(listener).toHaveBeenCalledTimes(2); - useSpriteEditPreviewStore - .getState() - .publish({ kind: 'pivot', positionId: 'sprite-1' }); - expect(listener).toHaveBeenCalledTimes(2); - useSpriteEditPreviewStore.getState().clear(); - expect(listener).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenCalledTimes(2); useSpriteEditPreviewStore.getState().clear(); - expect(listener).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenCalledTimes(2); unsubscribe(); }); - it('자세 전환·preferFallback·종류 변경은 발행을 갱신한다', () => { + it('자세 전환·preferFallback 변경은 발행을 갱신한다', () => { const store = useSpriteEditPreviewStore.getState(); store.publish({ kind: 'pose', @@ -63,7 +54,7 @@ describe('useSpriteEditPreviewStore', () => { preferFallback: false, }); let preview = useSpriteEditPreviewStore.getState().preview; - expect(preview?.kind === 'pose' && preview.poseId).toBe('b'); + expect(preview?.poseId).toBe('b'); store.publish({ kind: 'pose', @@ -73,9 +64,6 @@ describe('useSpriteEditPreviewStore', () => { preferFallback: true, }); preview = useSpriteEditPreviewStore.getState().preview; - expect(preview?.kind === 'pose' && preview.preferFallback).toBe(true); - - store.publish({ kind: 'pivot', positionId: 'sprite-1' }); - expect(useSpriteEditPreviewStore.getState().preview?.kind).toBe('pivot'); + expect(preview?.preferFallback).toBe(true); }); }); diff --git a/src/renderer/utils/sprite/contactSolver.test.ts b/src/renderer/utils/sprite/contactSolver.test.ts deleted file mode 100644 index fe8e9645c..000000000 --- a/src/renderer/utils/sprite/contactSolver.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - contactWorldPosition, - solveTransformTowardTarget, - solveTranslationKeepingContact, - type ContactGeometry, -} from './contactSolver'; - -// 축(0.5,0.25)·핀(0.5,1) - 위에서 매달린 팔 형태, 기본 길이 150px -const geometry: ContactGeometry = { - imageRect: { x: 10, y: 20, width: 100, height: 200 }, - pivot: { x: 0.5, y: 0.25 }, - contactPoint: { x: 0.5, y: 1 }, -}; - -const identity = { x: 0, y: 0, rotation: 0, scale: 1 }; - -describe('contactWorldPosition', () => { - it('항등 변환에서는 핀의 rect 좌표 그대로다', () => { - expect(contactWorldPosition(geometry, identity)).toEqual({ - x: 60, - y: 220, - }); - }); - - it('이동·회전·배율을 축 기준으로 합성한다', () => { - // 90도 회전: 아래로 뻗은 벡터(0,150)가 왼쪽(-150,0)으로 눕는다 - const world = contactWorldPosition(geometry, { - x: 7, - y: -3, - rotation: 90, - scale: 2, - }); - expect(world.x).toBeCloseTo(7 + 60 - 300, 6); - expect(world.y).toBeCloseTo(-3 + 70, 6); - }); -}); - -describe('solveTransformTowardTarget (축 고정)', () => { - it('현재 핀 위치를 목표로 주면 transform이 보존된다 - 왕복 항등성', () => { - const current = { x: 12, y: -8, rotation: 33, scale: 1.4 }; - const world = contactWorldPosition(geometry, current); - const solved = solveTransformTowardTarget(geometry, current, world, true); - expect(solved.status).toBe('ok'); - if (solved.status !== 'ok') return; - expect(solved.transform.x).toBe(current.x); - expect(solved.transform.y).toBe(current.y); - expect(solved.transform.rotation).toBeCloseTo(current.rotation, 6); - expect(solved.transform.scale).toBeCloseTo(current.scale, 6); - }); - - it('역산된 transform은 핀을 목표에 정확히 올린다', () => { - const target = { x: -40, y: 90 }; - const solved = solveTransformTowardTarget(geometry, identity, target, true); - expect(solved.status).toBe('ok'); - if (solved.status !== 'ok') return; - const world = contactWorldPosition(geometry, solved.transform); - expect(world.x).toBeCloseTo(target.x, 6); - expect(world.y).toBeCloseTo(target.y, 6); - }); - - it('뻗기 OFF면 scale을 유지하고 방향각만 맞춘다', () => { - const solved = solveTransformTowardTarget( - geometry, - { ...identity, scale: 1.5 }, - { x: 300, y: 220 }, - false, - ); - expect(solved.status).toBe('ok'); - if (solved.status !== 'ok') return; - expect(solved.transform.scale).toBe(1.5); - // 핀은 호 위의 최근접점 - 목표 방향은 일치한다 - const axisWorld = { x: 60, y: 70 }; - const world = contactWorldPosition(geometry, solved.transform); - const targetAngle = Math.atan2(220 - axisWorld.y, 300 - axisWorld.x); - const worldAngle = Math.atan2(world.y - axisWorld.y, world.x - axisWorld.x); - expect(worldAngle).toBeCloseTo(targetAngle, 6); - }); - - it('scale·rotation은 계약 범위로 clamp된다', () => { - // 기본 길이 150px, 목표 거리 6000px -> scale 40은 상한 10으로 - const solved = solveTransformTowardTarget( - geometry, - identity, - { x: 60, y: 70 + 6000 }, - true, - ); - expect(solved.status).toBe('ok'); - if (solved.status !== 'ok') return; - expect(solved.transform.scale).toBe(10); - }); - - it('핀=축 퇴화는 transform을 건드리지 않고 degenerate를 반환한다', () => { - const degenerate = solveTransformTowardTarget( - { ...geometry, contactPoint: geometry.pivot }, - identity, - { x: 100, y: 100 }, - true, - ); - expect(degenerate).toEqual({ status: 'degenerate' }); - }); - - it('목표가 축 위인 퇴화도 degenerate다', () => { - const solved = solveTransformTowardTarget( - geometry, - identity, - { x: 60, y: 70 }, - true, - ); - expect(solved).toEqual({ status: 'degenerate' }); - }); -}); - -describe('solveTranslationKeepingContact (핀 고정)', () => { - it('회전이 바뀌어도 핀 월드 위치가 유지된다', () => { - const current = { x: 5, y: 5, rotation: 10, scale: 1.2 }; - const pinned = contactWorldPosition(geometry, current); - const solved = solveTranslationKeepingContact( - geometry, - { rotation: -70, scale: 1.2 }, - pinned, - ); - expect(solved.status).toBe('ok'); - if (solved.status !== 'ok') return; - const world = contactWorldPosition(geometry, solved.transform); - expect(world.x).toBeCloseTo(pinned.x, 6); - expect(world.y).toBeCloseTo(pinned.y, 6); - }); - - it('배율 변경도 핀을 고정한다', () => { - const pinned = contactWorldPosition(geometry, identity); - const solved = solveTranslationKeepingContact( - geometry, - { rotation: 0, scale: 2 }, - pinned, - ); - expect(solved.status).toBe('ok'); - if (solved.status !== 'ok') return; - const world = contactWorldPosition(geometry, solved.transform); - expect(world.x).toBeCloseTo(pinned.x, 6); - expect(world.y).toBeCloseTo(pinned.y, 6); - }); - - it('offset 상한에 걸리면 닿는 데까지만 간다', () => { - const solved = solveTranslationKeepingContact( - geometry, - { rotation: 180, scale: 10 }, - { x: 5000, y: 5000 }, - ); - expect(solved.status).toBe('ok'); - if (solved.status !== 'ok') return; - expect(solved.transform.x).toBeLessThanOrEqual(2000); - expect(solved.transform.y).toBeLessThanOrEqual(2000); - }); - - it('핀=축 퇴화는 degenerate다', () => { - const solved = solveTranslationKeepingContact( - { ...geometry, contactPoint: geometry.pivot }, - { rotation: 30, scale: 1 }, - { x: 0, y: 0 }, - ); - expect(solved).toEqual({ status: 'degenerate' }); - }); -}); diff --git a/src/renderer/utils/sprite/poseResolver.test.ts b/src/renderer/utils/sprite/poseResolver.test.ts index ff5f2fbde..3d53f3296 100644 --- a/src/renderer/utils/sprite/poseResolver.test.ts +++ b/src/renderer/utils/sprite/poseResolver.test.ts @@ -40,9 +40,7 @@ const makeSprite = ( width: 200, height: 120, baseImage: 'base.png', - imageRect: { x: 0, y: 0, width: 100, height: 100 }, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, poses, ...overrides, @@ -306,22 +304,28 @@ describe('resolveSpriteTarget 진리표', () => { }); describe('resolveSpriteTarget visual', () => { - it('정확 일치 자세는 자기 이미지·축을, 평균 상태는 첫 override 자세의 축을 쓴다', () => { + it('정확 일치 자세는 자기 이미지·원본 크기를, 평균 상태는 첫 override 자세의 것을 쓴다', () => { const a = makePose('a', ['k1'], { x: 10 }, 'a.png'); const b = makePose('b', ['k2'], { x: 30 }, 'b.png'); const sprite = makeSprite([ - { ...a, imagePivot: { x: 0.1, y: 0.9 } }, - { ...b, imagePivot: { x: 0.2, y: 0.8 } }, + { + ...a, + imageOverrideMetrics: { source: 'a.png', width: 100, height: 300 }, + }, + { + ...b, + imageOverrideMetrics: { source: 'b.png', width: 200, height: 200 }, + }, ]); const exact = resolveSpriteTarget(sprite, new Set(['k1'])); expect(exact.visual.sourcePoseId).toBe('a'); - expect(exact.visual.pivot).toEqual({ x: 0.1, y: 0.9 }); + expect(exact.visual.naturalSize).toEqual({ width: 100, height: 300 }); const mixed = resolveSpriteTarget(sprite, new Set(['k1', 'k2'])); expect(mixed.imageSrc).toBe('a.png'); expect(mixed.visual.sourcePoseId).toBe('a'); - expect(mixed.visual.pivot).toEqual({ x: 0.1, y: 0.9 }); + expect(mixed.visual.naturalSize).toEqual({ width: 100, height: 300 }); const idle = resolveSpriteTarget(sprite, new Set()); expect(idle.visual.sourcePoseId).toBeNull(); - expect(idle.visual.pivot).toBe(sprite.pivot); + expect(idle.visual.naturalSize).toBeNull(); }); }); diff --git a/src/renderer/utils/sprite/resizeProjection.test.ts b/src/renderer/utils/sprite/resizeProjection.test.ts index 8687dfc00..b2a5edd4c 100644 --- a/src/renderer/utils/sprite/resizeProjection.test.ts +++ b/src/renderer/utils/sprite/resizeProjection.test.ts @@ -12,15 +12,12 @@ const basePosition = () => ({ dy: 20, width: 200, height: 100, - imageRect: { x: 40, y: -10, width: 160, height: 80 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 12, y: -6, rotation: 15, scale: 1.5 }, poses: [ { poseId: 'pose-1', transform: { x: -30, y: 44, rotation: -90, scale: 0.5 }, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, }, ], @@ -28,7 +25,7 @@ const basePosition = () => ({ }); describe('projectSpriteResize', () => { - it('bounds 교체와 함께 imageRect·transform 오프셋을 축별 배율로 스케일한다', () => { + it('bounds 교체와 함께 transform 오프셋을 축별 배율로 스케일한다', () => { const next = projectSpriteResize(basePosition(), { dx: 5, dy: 8, @@ -40,7 +37,6 @@ describe('projectSpriteResize', () => { expect(next.width).toBe(400); expect(next.height).toBe(50); // sx=2, sy=0.5 - expect(next.imageRect).toEqual({ x: 80, y: -5, width: 320, height: 40 }); expect(next.idleTransform).toEqual({ x: 24, y: -3, @@ -55,7 +51,7 @@ describe('projectSpriteResize', () => { }); }); - it('rotation·scale·pivot·contactPoint와 나머지 필드는 불변이다', () => { + it('rotation·scale·pivot과 나머지 필드는 불변이다', () => { const position = basePosition(); const next = projectSpriteResize(position, { dx: 0, @@ -64,7 +60,6 @@ describe('projectSpriteResize', () => { height: 50, }); expect(next.pivot).toEqual(position.pivot); - expect(next.poses[0].contactPoint).toEqual(position.poses[0].contactPoint); expect(next.poses[0].poseId).toBe('pose-1'); expect(next.id).toBe(position.id); expect(next.transitionMs).toBe(90); @@ -89,15 +84,13 @@ describe('projectSpriteResize', () => { width: 200, height: 100, }); - expect(next.imageRect).toEqual(position.imageRect); expect(next.idleTransform).toEqual(position.idleTransform); expect(next.poses[0].transform).toEqual(position.poses[0].transform); }); - it('배율 1은 클램프 없는 passthrough - 하한 미만 치수·범위 밖 오프셋 보존', () => { + it('배율 1은 클램프 없는 passthrough - 범위 밖 오프셋 보존', () => { const position = { ...basePosition(), - imageRect: { x: 40, y: -10, width: 1e-7, height: 80 }, idleTransform: { x: 5000, y: -6, rotation: 0, scale: 1 }, }; const next = projectSpriteResize(position, { @@ -106,11 +99,10 @@ describe('projectSpriteResize', () => { width: 200, height: 100, }); - expect(next.imageRect.width).toBe(1e-7); expect(next.idleTransform.x).toBe(5000); }); - it('스케일 결과는 offset·imageRect 한계에 클램프된다', () => { + it('스케일 결과는 offset 한계에 클램프된다', () => { const position = { ...basePosition(), idleTransform: { x: 1500, y: -1500, rotation: 0, scale: 1 }, @@ -135,8 +127,7 @@ describe('projectSpriteResize', () => { height: 100, }); expect(next.width).toBe(300); - expect(next.imageRect.x).toBe(40); - expect(next.imageRect.width).toBe(160); + expect(next.idleTransform).toEqual(position.idleTransform); }); }); diff --git a/src/renderer/utils/sprite/spriteFixtures.ts b/src/renderer/utils/sprite/spriteFixtures.ts index 275df4910..1be3fd337 100644 --- a/src/renderer/utils/sprite/spriteFixtures.ts +++ b/src/renderer/utils/sprite/spriteFixtures.ts @@ -5,8 +5,6 @@ import type { } from '@src/types/key/sprites'; import { DEFAULT_SPRITE_ACTIVATION, - DEFAULT_SPRITE_CONTACT_POINT, - DEFAULT_SPRITE_IMAGE_PLACEMENT, DEFAULT_SPRITE_PRESS_DURATION_MS, DEFAULT_SPRITE_SIZE, DEFAULT_SPRITE_TRANSITION_EASING, @@ -25,8 +23,6 @@ export const makeSpritePose = ( triggers: [], transform: { ...IDENTITY_SPRITE_TRANSFORM }, imageOverride: null, - contactPoint: { ...DEFAULT_SPRITE_CONTACT_POINT }, - imagePivot: null, imageOverrideMetrics: null, ...overrides, }); @@ -46,13 +42,6 @@ export const makeSpritePosition = ( className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { - x: 0, - y: 0, - width: DEFAULT_SPRITE_SIZE, - height: DEFAULT_SPRITE_SIZE, - }, pivot: { ...CENTER_SPRITE_ANCHOR }, idleTransform: { ...IDENTITY_SPRITE_TRANSFORM }, activation: DEFAULT_SPRITE_ACTIVATION, @@ -60,7 +49,6 @@ export const makeSpritePosition = ( poses: [], transitionMs: 90, transitionEasing: DEFAULT_SPRITE_TRANSITION_EASING, - imagePlacement: DEFAULT_SPRITE_IMAGE_PLACEMENT, referenceNaturalSize: null, ...overrides, }); diff --git a/src/renderer/utils/sprite/spritePlacement.test.ts b/src/renderer/utils/sprite/spritePlacement.test.ts index 08c5f9a93..f48470201 100644 --- a/src/renderer/utils/sprite/spritePlacement.test.ts +++ b/src/renderer/utils/sprite/spritePlacement.test.ts @@ -1,28 +1,93 @@ import { describe, expect, it } from 'vitest'; +import type { + ReactiveSpritePosition, + SpritePose, +} from '@src/types/key/sprites'; + import { makeSpritePose, makeSpritePosition } from './spriteFixtures'; import { - fitImageRectToNaturalSize, + boxSpritePlacement, + compensateTransformForPivotChange, + fitSpriteBoundsToNaturalSize, + isSameSpritePlacement, placeSpriteVisual, spriteIdleVisual, spritePoseVisual, spriteReferenceScale, spriteReferenceSize, + spritePivotChangePatch, + compensateTransformForPivotDelta, + compensateTransformForPosePivotDelta, + pivotForHandleTarget, + pivotHandleLocalPoint, + posePivotForHandleTarget, + posePivotHandleLocalPoint, + snapPivotToPreset, + snapPosePivotToPreset, + spritePosePivotChangePatch, } from './spritePlacement'; -// 상자 200x100, 기준 이미지 400x200(contain 배율 0.5), 축은 상자 가운데 -const pivotSprite = () => +// 상자 200x100, 기준 이미지 400x200(배율 0.5), 기준점은 상자 가운데 +const referenceSprite = () => makeSpritePosition({ baseImage: 'base.png', - imageRect: { x: 10, y: 20, width: 200, height: 100 }, + width: 200, + height: 100, pivot: { x: 0.5, y: 0.5 }, - imagePlacement: 'pivot', referenceNaturalSize: { source: 'base.png', width: 400, height: 200 }, }); +const renderedPoseCorners = ( + sprite: ReactiveSpritePosition, + pose: SpritePose, +): Array<{ x: number; y: number }> => { + const placement = placeSpriteVisual(sprite, spritePoseVisual(sprite, pose)); + const axis = { + x: sprite.pivot.x * sprite.width, + y: sprite.pivot.y * sprite.height, + }; + const rad = (pose.transform.rotation * Math.PI) / 180; + const cos = Math.cos(rad) * pose.transform.scale; + const sin = Math.sin(rad) * pose.transform.scale; + const { rect } = placement; + return [ + { x: rect.x, y: rect.y }, + { x: rect.x + rect.width, y: rect.y }, + { x: rect.x + rect.width, y: rect.y + rect.height }, + { x: rect.x, y: rect.y + rect.height }, + ].map((corner) => { + const dx = corner.x - axis.x; + const dy = corner.y - axis.y; + return { + x: axis.x + pose.transform.x + dx * cos - dy * sin, + y: axis.y + pose.transform.y + dx * sin + dy * cos, + }; + }); +}; + +const renderedPoseAxis = ( + sprite: ReactiveSpritePosition, + pose: SpritePose, +): { x: number; y: number } => ({ + x: sprite.pivot.x * sprite.width + pose.transform.x, + y: sprite.pivot.y * sprite.height + pose.transform.y, +}); + +const expectSameCorners = ( + actual: Array<{ x: number; y: number }>, + expected: Array<{ x: number; y: number }>, +) => { + expect(actual).toHaveLength(expected.length); + actual.forEach((corner, index) => { + expect(corner.x).toBeCloseTo(expected[index].x, 8); + expect(corner.y).toBeCloseTo(expected[index].y, 8); + }); +}; + describe('spriteReferenceSize', () => { it('base가 있으면 base에 결합된 기준만 유효하다', () => { - expect(spriteReferenceSize(pivotSprite())).toEqual({ + expect(spriteReferenceSize(referenceSprite())).toEqual({ width: 400, height: 200, }); @@ -51,85 +116,98 @@ describe('spriteReferenceSize', () => { }); describe('spriteReferenceScale', () => { - it('box 모드는 배율이 없다', () => { - expect( - spriteReferenceScale({ ...pivotSprite(), imagePlacement: 'box' }), - ).toBeNull(); - }); - - it('fit별 배율 - contain은 min, cover는 max, fill은 축별', () => { - const sprite = { - ...pivotSprite(), - imageRect: { x: 0, y: 0, width: 200, height: 200 }, - }; - expect(spriteReferenceScale({ ...sprite, imageFit: null })).toEqual({ + it('상자 ÷ 기준 크기의 축별 배율', () => { + expect(spriteReferenceScale(referenceSprite())).toEqual({ x: 0.5, y: 0.5, }); - expect(spriteReferenceScale({ ...sprite, imageFit: 'cover' })).toEqual({ - x: 1, - y: 1, - }); - expect(spriteReferenceScale({ ...sprite, imageFit: 'fill' })).toEqual({ - x: 0.5, - y: 1, - }); + expect( + spriteReferenceScale({ ...referenceSprite(), width: 200, height: 200 }), + ).toEqual({ x: 0.5, y: 1 }); + }); + + it('기준 크기가 없거나 stale이면 배율이 없다', () => { + expect( + spriteReferenceScale({ + ...referenceSprite(), + referenceNaturalSize: null, + }), + ).toBeNull(); + expect( + spriteReferenceScale({ ...referenceSprite(), baseImage: 'other.png' }), + ).toBeNull(); }); }); describe('placeSpriteVisual', () => { - it('box 모드는 저장된 imageRect·기준점 참조를 그대로 돌려준다', () => { + it('기준 크기가 없으면 이미지를 요소 상자에 그대로 놓는다', () => { const sprite = makeSpritePosition({ baseImage: 'base.png' }); const placement = placeSpriteVisual(sprite, spriteIdleVisual(sprite)); - expect(placement.rect).toBe(sprite.imageRect); + expect(placement).toEqual(boxSpritePlacement(sprite)); + expect(placement.rect).toEqual({ x: 0, y: 0, width: 200, height: 200 }); expect(placement.pivot).toBe(sprite.pivot); }); - it('기준 이미지는 비율이 같으면 상자와 같은 자리에 놓인다', () => { - const sprite = pivotSprite(); + it('기본 이미지는 항상 요소 상자를 정확히 채운다', () => { + const sprite = referenceSprite(); const placement = placeSpriteVisual(sprite, spriteIdleVisual(sprite)); - expect(placement.rect).toEqual({ x: 10, y: 20, width: 200, height: 100 }); + expect(placement.rect).toEqual({ x: 0, y: 0, width: 200, height: 100 }); expect(placement.pivot).toEqual({ x: 0.5, y: 0.5 }); }); - it('비율이 다른 자세 이미지는 자기 축을 기준점에 맞추고 기준 배율로 그려진다', () => { - const sprite = pivotSprite(); + it('크기가 다른 자세 이미지는 같은 기준점을 P에 맞추고 기준 배율로 그려진다', () => { + const sprite = referenceSprite(); const pose = makeSpritePose({ poseId: 'p', imageOverride: 'hand.png', imageOverrideMetrics: { source: 'hand.png', width: 400, height: 400 }, - imagePivot: { x: 0.5, y: 1 }, }); const visual = spritePoseVisual(sprite, pose); expect(visual.sourcePoseId).toBe('p'); + expect(visual.naturalSize).toEqual({ width: 400, height: 400 }); const placement = placeSpriteVisual(sprite, visual); - // 축 P=(110, 70), W·H=200, 축이 이미지 바닥 가운데 - expect(placement.rect).toEqual({ x: 10, y: -130, width: 200, height: 200 }); - expect(placement.pivot).toEqual({ x: 0.5, y: 1 }); + // P=(100, 50), W·H=200, 기준점이 이미지 가운데 + expect(placement.rect).toEqual({ x: 0, y: -50, width: 200, height: 200 }); + expect(placement.pivot).toBe(sprite.pivot); }); - it('자세 축이 없으면 스프라이트 기준점을 물려받는다', () => { - const sprite = pivotSprite(); + it('독립 상태 기준점은 그 이미지의 점을 공통 축 P에 맞춘다', () => { + const sprite = referenceSprite(); const pose = makeSpritePose({ + poseId: 'p', + pivot: { x: 0.25, y: 0.75 }, imageOverride: 'hand.png', - imageOverrideMetrics: { source: 'hand.png', width: 100, height: 100 }, + imageOverrideMetrics: { source: 'hand.png', width: 400, height: 400 }, + }); + const placement = placeSpriteVisual(sprite, spritePoseVisual(sprite, pose)); + // P=(100, 50), W·H=200, 상태 기준점=(50, 150) + expect(placement.rect).toEqual({ + x: 50, + y: -100, + width: 200, + height: 200, }); - expect(spritePoseVisual(sprite, pose).pivot).toBe(sprite.pivot); + expect(placement.pivot).toEqual({ x: 0.25, y: 0.75 }); }); - it('경로가 어긋난 크기는 stale이라 box 배치로 폴백한다', () => { - const sprite = pivotSprite(); + it('경로가 어긋난 크기는 stale이라 상자 배치로 폴백한다', () => { + const sprite = referenceSprite(); const pose = makeSpritePose({ imageOverride: 'new.png', imageOverrideMetrics: { source: 'old.png', width: 400, height: 400 }, }); const visual = spritePoseVisual(sprite, pose); expect(visual.naturalSize).toBeNull(); - expect(placeSpriteVisual(sprite, visual).rect).toBe(sprite.imageRect); + expect(placeSpriteVisual(sprite, visual).rect).toEqual({ + x: 0, + y: 0, + width: 200, + height: 100, + }); }); it('공백 override는 기본 이미지 visual이다', () => { - const sprite = pivotSprite(); + const sprite = referenceSprite(); const visual = spritePoseVisual( sprite, makeSpritePose({ imageOverride: ' ' }), @@ -139,32 +217,305 @@ describe('placeSpriteVisual', () => { }); }); -describe('fitImageRectToNaturalSize', () => { +describe('isSameSpritePlacement', () => { + it('rect와 기준점이 값으로 같을 때만 참이다', () => { + const sprite = referenceSprite(); + const a = placeSpriteVisual(sprite, spriteIdleVisual(sprite)); + const b = placeSpriteVisual( + structuredClone(sprite), + spriteIdleVisual(sprite), + ); + expect(isSameSpritePlacement(a, b)).toBe(true); + expect(isSameSpritePlacement(a, { ...a, pivot: { x: 0, y: 0.5 } })).toBe( + false, + ); + expect( + isSameSpritePlacement(a, { ...a, rect: { ...a.rect, width: 1 } }), + ).toBe(false); + }); +}); + +describe('fitSpriteBoundsToNaturalSize', () => { it('상자를 이미지 비율로 줄이되 기준점 위치는 그대로 둔다', () => { - const rect = { x: 10, y: 20, width: 200, height: 200 }; - // 가운데 기준점 - contain이 그리던 자리 그대로 상자만 감싼다 + const bounds = { dx: 10, dy: 20, width: 200, height: 200 }; + // 가운데 기준점 - 상자가 세로로 줄어도 P는 제자리 expect( - fitImageRectToNaturalSize( - rect, + fitSpriteBoundsToNaturalSize( + bounds, { x: 0.5, y: 0.5 }, { width: 400, height: 200 }, ), - ).toEqual({ x: 10, y: 70, width: 200, height: 100 }); + ).toEqual({ dx: 10, dy: 70, width: 200, height: 100 }); // 왼쪽 위 기준점 - 비트맵 왼쪽 위가 상자 왼쪽 위에 붙는다 expect( - fitImageRectToNaturalSize( - rect, + fitSpriteBoundsToNaturalSize( + bounds, { x: 0, y: 0 }, { width: 400, height: 200 }, ), - ).toEqual({ x: 10, y: 20, width: 200, height: 100 }); + ).toEqual({ dx: 10, dy: 20, width: 200, height: 100 }); // 비율이 이미 같으면 변화 없음 expect( - fitImageRectToNaturalSize( - rect, + fitSpriteBoundsToNaturalSize( + bounds, { x: 0.3, y: 0.9 }, { width: 50, height: 50 }, ), - ).toEqual(rect); + ).toEqual(bounds); + }); +}); + +describe('compensateTransformForPivotChange', () => { + it('회전·배율 없는 기본 이미지는 기준점을 옮겨도 이동값이 변하지 않는다', () => { + // t' = t + (I − sR)(P − P'), sR = I이면 보정항이 0 + const sprite = referenceSprite(); + const transform = { x: 12, y: -6, rotation: 0, scale: 1 }; + expect( + compensateTransformForPivotChange( + sprite, + spriteIdleVisual(sprite), + transform, + { x: 0, y: 0 }, + ), + ).toEqual(transform); + }); + + it('배율 2 기본 이미지는 기준점 이동만큼 그림이 밀리지 않게 보정한다', () => { + // P=(100, 50) → P'=(0, 0), (I − 2I)(P − P') = −(100, 50) + const sprite = referenceSprite(); + const transform = { x: 0, y: 0, rotation: 0, scale: 2 }; + expect( + compensateTransformForPivotChange( + sprite, + spriteIdleVisual(sprite), + transform, + { x: 0, y: 0 }, + ), + ).toEqual({ x: -100, y: -50, rotation: 0, scale: 2 }); + }); +}); + +describe('spritePivotChangePatch', () => { + it('보정이 이동값 범위를 넘는 기준점은 patch를 만들지 않는다', () => { + // 200x100 기본 이미지, 180° 회전·배율 10, x=1900에서 기준점을 (0.5,0.5)→(0,0.5)로 + // 옮기면 정확한 보정 x는 3000 - 범위(±2000)를 넘으므로 일부만 클램프해 그림이 + // 움직이는 대신 변경 자체를 거절한다 + const sprite = makeSpritePosition({ + width: 200, + height: 100, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 1900, y: 0, rotation: 180, scale: 10 }, + }); + expect(spritePivotChangePatch(sprite, { x: 0, y: 0.5 })).toBeNull(); + expect( + compensateTransformForPivotChange( + sprite, + spriteIdleVisual(sprite), + sprite.idleTransform, + { x: 0, y: 0.5 }, + ), + ).toBeNull(); + }); + + it('범위 안이면 기본 이미지를 보정하고 연결 상태는 새 축을 따라가게 한다', () => { + const sprite = makeSpritePosition({ + width: 200, + height: 100, + pivot: { x: 0.5, y: 0.5 }, + idleTransform: { x: 0, y: 0, rotation: 0, scale: 2 }, + poses: [ + makeSpritePose({ + poseId: 'p', + triggers: ['k'], + transform: { x: 10, y: 0, rotation: 90, scale: 1 }, + }), + ], + }); + const patch = spritePivotChangePatch(sprite, { x: 0, y: 0 }); + expect(patch).not.toBeNull(); + expect(patch!.pivot).toEqual({ x: 0, y: 0 }); + // 기본: (I − 2I)(P − P') = −(100, 50) + expect(patch!.idleTransform).toEqual({ + x: -100, + y: -50, + rotation: 0, + scale: 2, + }); + // 연결 상태는 이동값을 보정하지 않아 기준점 화면 좌표가 기본 축과 함께 움직인다 + expect(patch!.poses[0].transform.x).toBe(10); + expect(patch!.poses[0].transform.y).toBe(0); + expect(patch!.poses[0].transform.rotation).toBe(90); + }); + + it('공통 기준점 변경은 연결 상태 축만 따라가고 독립 상태 이미지는 제자리에 둔다', () => { + const sprite = referenceSprite(); + sprite.poses = [ + makeSpritePose({ + poseId: 'linked', + pivot: null, + transform: { x: 7, y: -4, rotation: 23, scale: 1.3 }, + imageOverride: 'linked.png', + imageOverrideMetrics: { + source: 'linked.png', + width: 300, + height: 500, + }, + }), + makeSpritePose({ + poseId: 'independent', + pivot: { x: 0.2, y: 0.8 }, + transform: { x: -11, y: 9, rotation: -41, scale: 0.7 }, + imageOverride: 'independent.png', + imageOverrideMetrics: { + source: 'independent.png', + width: 500, + height: 300, + }, + }), + ]; + const linkedAxisBefore = renderedPoseAxis(sprite, sprite.poses[0]); + const independentBefore = renderedPoseCorners(sprite, sprite.poses[1]); + const patch = spritePivotChangePatch(sprite, { x: 0.1, y: 0.9 })!; + const changed = { ...sprite, ...patch }; + + expect(changed.poses[0].pivot).toBeNull(); + expect(changed.poses[0].transform).toEqual(sprite.poses[0].transform); + expect(changed.poses[1].pivot).toEqual({ x: 0.2, y: 0.8 }); + const linkedAxisAfter = renderedPoseAxis(changed, changed.poses[0]); + expect(linkedAxisAfter.x - linkedAxisBefore.x).toBeCloseTo(-80, 8); + expect(linkedAxisAfter.y - linkedAxisBefore.y).toBeCloseTo(40, 8); + expectSameCorners( + renderedPoseCorners(changed, changed.poses[1]), + independentBefore, + ); + }); +}); + +describe('spritePosePivotChangePatch', () => { + it('상태 기준점을 분리하고 다시 연결해도 이미지 모서리가 움직이지 않는다', () => { + const sprite = referenceSprite(); + const linked = makeSpritePose({ + poseId: 'pose', + pivot: null, + transform: { x: 12, y: -8, rotation: 37, scale: 1.4 }, + imageOverride: 'pose.png', + imageOverrideMetrics: { source: 'pose.png', width: 360, height: 440 }, + }); + const before = renderedPoseCorners(sprite, linked); + + const detachedPatch = spritePosePivotChangePatch(sprite, linked, { + x: 0.15, + y: 0.85, + })!; + const detached = { ...linked, ...detachedPatch }; + expect(detached.pivot).toEqual({ x: 0.15, y: 0.85 }); + expectSameCorners(renderedPoseCorners(sprite, detached), before); + + const linkedPatch = spritePosePivotChangePatch(sprite, detached, null)!; + const relinked = { ...detached, ...linkedPatch }; + expect(relinked.pivot).toBeNull(); + expectSameCorners(renderedPoseCorners(sprite, relinked), before); + }); +}); + +describe('기준점 드래그 프레임', () => { + const frame = { + box: { width: 200, height: 100 }, + rect: { width: 200, height: 100 }, + pivot: { x: 0.5, y: 0.5 }, + transform: { x: 10, y: 5, rotation: 90, scale: 0.5 }, + }; + + it('표식 자리는 P + t + sR·Δ이고 역변환이 같은 기준점을 되돌린다', () => { + const next = { x: 0.8, y: 0.3 }; + // Δ = (60, −20) → R(90°)·Δ·0.5 = (10, 30) → (100+10+10, 50+5+30) + const at = pivotHandleLocalPoint(frame, next); + expect(at.x).toBeCloseTo(120, 9); + expect(at.y).toBeCloseTo(85, 9); + const back = pivotForHandleTarget(frame, at); + expect(back.x).toBeCloseTo(0.8, 9); + expect(back.y).toBeCloseTo(0.3, 9); + // 보정 transform으로 그린 P' + t'도 같은 자리 + const compensated = compensateTransformForPivotDelta(frame, next)!; + expect(160 + compensated.x).toBeCloseTo(120, 9); + expect(30 + compensated.y).toBeCloseTo(85, 9); + }); + + it('배율이 작으면 포인터 이동량이 그만큼 큰 기준점 변화가 된다', () => { + const small = { + ...frame, + transform: { x: 0, y: 0, rotation: 0, scale: 0.1 }, + }; + // 표식 5px 이동 = 이미지 좌표 50px = 기준점 0.25 + const next = pivotForHandleTarget(small, { x: 105, y: 50 }); + expect(next.x).toBeCloseTo(0.75, 9); + expect(next.y).toBeCloseTo(0.5, 9); + expect(pivotHandleLocalPoint(small, next).x).toBeCloseTo(105, 9); + }); + + it('닿을 수 없는 target은 0..1로 잘린다', () => { + expect(pivotForHandleTarget(frame, { x: 10000, y: -10000 })).toEqual({ + x: 0, + y: 0, + }); + }); +}); + +describe('상태 기준점 드래그 프레임', () => { + const frame = { + axis: { x: 100, y: 50 }, + rect: { width: 240, height: 120 }, + pivot: { x: 0.25, y: 0.75 }, + transform: { x: -6, y: 9, rotation: -30, scale: 1.25 }, + }; + + it('표식 자리와 역변환이 같은 상태 기준점을 되돌린다', () => { + const next = { x: 0.8, y: 0.2 }; + const at = posePivotHandleLocalPoint(frame, next); + const back = posePivotForHandleTarget(frame, at); + expect(back.x).toBeCloseTo(next.x, 9); + expect(back.y).toBeCloseTo(next.y, 9); + const compensated = compensateTransformForPosePivotDelta(frame, next)!; + expect(frame.axis.x + compensated.x).toBeCloseTo(at.x, 9); + expect(frame.axis.y + compensated.y).toBeCloseTo(at.y, 9); + }); + + it('9점 프리셋은 상태 이미지 기준으로 스냅한다', () => { + const center = posePivotHandleLocalPoint(frame, { x: 0.5, y: 0.5 }); + expect( + snapPosePivotToPreset(frame, { x: center.x + 2, y: center.y - 2 }, 8), + ).toEqual({ x: 0.5, y: 0.5 }); + }); +}); + +describe('snapPivotToPreset', () => { + const frame = { + box: { width: 200, height: 150 }, + rect: { width: 200, height: 150 }, + pivot: { x: 0.5, y: 0.5 }, + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + }; + + it('반경 안 프리셋 중 가장 가까운 것을 고른다', () => { + // 오른쪽 변 중앙 (200, 75)에서 3px 안쪽 + expect(snapPivotToPreset(frame, { x: 197, y: 75 }, 8)).toEqual({ + x: 1, + y: 0.5, + }); + expect(snapPivotToPreset(frame, { x: 150, y: 75 }, 8)).toBeNull(); + }); + + it('배율이 작아 프리셋이 몰리면 반경을 이웃 간격 절반 아래로 눌러 옆 프리셋으로 튀지 않는다', () => { + const small = { + ...frame, + transform: { x: 0, y: 0, rotation: 0, scale: 0.1 }, + }; + // 이웃 간격 7.5px → 반경 3.25px. 중앙에서 1px 위는 위 프리셋(6.5px)이 아니라 중앙 + expect(snapPivotToPreset(small, { x: 100, y: 74 }, 8)).toEqual({ + x: 0.5, + y: 0.5, + }); + // 반경 밖은 스냅 없음 + expect(snapPivotToPreset(small, { x: 100, y: 71 }, 8)).toBeNull(); }); }); diff --git a/src/renderer/utils/sprite/spritePlacementSwitch.test.ts b/src/renderer/utils/sprite/spritePlacementSwitch.test.ts deleted file mode 100644 index 8de2e921b..000000000 --- a/src/renderer/utils/sprite/spritePlacementSwitch.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { makeSpritePose, makeSpritePosition } from './spriteFixtures'; -import { - pivotPlacementImageSources, - planPivotPlacementCommit, -} from './spritePlacementSwitch'; - -const size = (width: number, height: number) => ({ width, height }); - -describe('pivotPlacementImageSources', () => { - it('base와 자세 override를 중복 없이 모으고 공백은 뺀다', () => { - const sprite = makeSpritePosition({ - baseImage: 'base.png', - poses: [ - makeSpritePose({ poseId: 'a', imageOverride: 'hand.png' }), - makeSpritePose({ poseId: 'b', imageOverride: 'hand.png' }), - makeSpritePose({ poseId: 'c', imageOverride: ' ' }), - ], - }); - expect(pivotPlacementImageSources(sprite)).toEqual([ - 'base.png', - 'hand.png', - ]); - }); -}); - -describe('planPivotPlacementCommit', () => { - it('최신 자세에 읽은 크기를 경로 기준으로 병합하고 기준은 base에 결합한다', () => { - const latest = makeSpritePosition({ - baseImage: 'base.png', - poses: [ - makeSpritePose({ poseId: 'a', imageOverride: 'hand.png' }), - makeSpritePose({ poseId: 'b', imageOverride: null }), - ], - }); - const plan = planPivotPlacementCommit( - latest, - new Map([ - ['base.png', size(400, 200)], - ['hand.png', size(100, 300)], - ]), - ); - expect(plan?.referenceNaturalSize).toEqual({ - source: 'base.png', - width: 400, - height: 200, - }); - expect(plan?.poses[0].imageOverrideMetrics).toEqual({ - source: 'hand.png', - width: 100, - height: 300, - }); - expect(plan?.poses[1]).toBe(latest.poses[1]); - }); - - it('읽는 동안 이미지가 바뀌어 크기가 없는 경로가 생기면 계획을 포기한다', () => { - const latest = makeSpritePosition({ - baseImage: 'base.png', - poses: [makeSpritePose({ poseId: 'a', imageOverride: 'new.png' })], - }); - expect( - planPivotPlacementCommit( - latest, - new Map([ - ['base.png', size(1, 1)], - ['old.png', size(1, 1)], - ]), - ), - ).toBeNull(); - expect( - planPivotPlacementCommit( - { ...latest, baseImage: 'moved.png' }, - new Map([['base.png', size(1, 1)]]), - ), - ).toBeNull(); - }); - - it('base가 없으면 결합 없는 기존 기준을 두고, 없으면 첫 자세 이미지로 초기화한다', () => { - const kept = planPivotPlacementCommit( - makeSpritePosition({ - baseImage: null, - referenceNaturalSize: { source: null, width: 50, height: 60 }, - poses: [makeSpritePose({ poseId: 'a', imageOverride: 'hand.png' })], - }), - new Map([['hand.png', size(100, 300)]]), - ); - expect(kept?.referenceNaturalSize).toEqual({ - source: null, - width: 50, - height: 60, - }); - const initialized = planPivotPlacementCommit( - makeSpritePosition({ - baseImage: null, - referenceNaturalSize: { source: 'gone.png', width: 1, height: 1 }, - poses: [makeSpritePose({ poseId: 'a', imageOverride: 'hand.png' })], - }), - new Map([['hand.png', size(100, 300)]]), - ); - expect(initialized?.referenceNaturalSize).toEqual({ - source: null, - width: 100, - height: 300, - }); - // 이미지가 하나도 없으면 기준도 없다 - expect( - planPivotPlacementCommit(makeSpritePosition(), new Map()) - ?.referenceNaturalSize, - ).toBeNull(); - }); -}); diff --git a/src/renderer/utils/sprite/spritePoseIntent.test.ts b/src/renderer/utils/sprite/spritePoseIntent.test.ts new file mode 100644 index 000000000..9a784d0db --- /dev/null +++ b/src/renderer/utils/sprite/spritePoseIntent.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import type { SpritePose } from '@src/types/key/sprites'; +import { rebaseSpritePoseIntent } from './spritePoseIntent'; + +const pose = ( + poseId: string, + overrides: Partial = {}, +): SpritePose => ({ + poseId, + name: null, + triggers: [`key-${poseId}`], + transform: { x: 0, y: 0, rotation: 0, scale: 1 }, + pivot: null, + imageOverride: null, + imageOverrideMetrics: null, + ...overrides, +}); + +describe('rebaseSpritePoseIntent', () => { + it('대상 필드 변경만 최신 상태에 적용한다', () => { + const base = [pose('a')]; + const intended = [pose('a', { pivot: { x: 0.2, y: 0.8 } })]; + const current = [ + pose('a', { + transform: { x: 42, y: 7, rotation: 3, scale: 1.1 }, + imageOverride: 'latest.png', + }), + ]; + + expect(rebaseSpritePoseIntent(base, intended, current)).toEqual([ + pose('a', { + pivot: { x: 0.2, y: 0.8 }, + transform: { x: 42, y: 7, rotation: 3, scale: 1.1 }, + imageOverride: 'latest.png', + }), + ]); + }); + + it('미저장 초안의 여러 변경과 새 상태를 함께 보존한다', () => { + const base = [pose('a')]; + const intended = [ + pose('a', { + name: '상태 1', + transform: { x: 18, y: -4, rotation: 0, scale: 1 }, + }), + pose('b', { triggers: ['key-b'] }), + ]; + const current = [pose('a', { imageOverride: 'latest.png' })]; + + expect(rebaseSpritePoseIntent(base, intended, current)).toEqual([ + pose('a', { + name: '상태 1', + transform: { x: 18, y: -4, rotation: 0, scale: 1 }, + imageOverride: 'latest.png', + }), + pose('b', { triggers: ['key-b'] }), + ]); + }); + + it('삭제 의도는 대상만 제거하고 동시에 추가된 상태는 유지한다', () => { + const base = [pose('a'), pose('b')]; + const intended = [pose('a')]; + const current = [pose('a'), pose('b'), pose('c')]; + + expect(rebaseSpritePoseIntent(base, intended, current)).toEqual([ + pose('a'), + pose('c'), + ]); + }); + + it('먼저 착지한 로컬 추가에는 후속 초안 전체를 적용한다', () => { + const base = [pose('a')]; + const intended = [ + pose('a'), + pose('b', { + triggers: ['key-b'], + transform: { x: 12, y: 3, rotation: 0, scale: 1 }, + }), + ]; + const current = [pose('a'), pose('b', { triggers: [] })]; + + expect(rebaseSpritePoseIntent(base, intended, current)[1]).toEqual( + pose('b', { + triggers: ['key-b'], + transform: { x: 12, y: 3, rotation: 0, scale: 1 }, + }), + ); + }); +}); diff --git a/src/renderer/utils/sprite/spriteReach.test.ts b/src/renderer/utils/sprite/spriteReach.test.ts index d8702c1db..31d8a60ee 100644 --- a/src/renderer/utils/sprite/spriteReach.test.ts +++ b/src/renderer/utils/sprite/spriteReach.test.ts @@ -53,15 +53,14 @@ const makePose = ( imageOverride, }); -// 활동 영역 200x200에 imageRect가 영역 전체, pivot 중앙 (기본 생성값 형태) +// 활동 영역 200x200, pivot 중앙 (기본 생성값 형태) const makeSprite = ( overrides: Partial = {}, ): SpriteReachGeometry => ({ baseImage: 'base.png', - imageRect: { x: 0, y: 0, width: 200, height: 200 }, + width: 200, + height: 200, pivot: { x: 0.5, y: 0.5 }, - imageFit: null, - imagePlacement: 'box', referenceNaturalSize: null, idleTransform: makeTransform(), poses: [], @@ -70,7 +69,7 @@ const makeSprite = ( }); describe('computeSpriteReachAabb', () => { - it('변환 없는 스프라이트는 imageRect 그대로', () => { + it('변환 없는 스프라이트는 요소 상자 그대로', () => { const reach = computeSpriteReachAabb(makeSprite()); expect(reach).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 }); }); @@ -583,11 +582,11 @@ describe('easingOvershootExtension', () => { }); }); -describe('computeSpriteReachAabb pivot 배치', () => { +describe('computeSpriteReachAabb 자세 이미지 배치', () => { it('자세 이미지의 배치가 상자보다 크면 그만큼 도달 범위가 넓어진다', () => { const reach = computeSpriteReachAabb( makeSprite({ - imagePlacement: 'pivot', + pivot: { x: 0.5, y: 1 }, referenceNaturalSize: { source: 'base.png', width: 200, height: 200 }, poses: [ makeSpritePose({ @@ -600,19 +599,17 @@ describe('computeSpriteReachAabb pivot 배치', () => { width: 200, height: 400, }, - imagePivot: { x: 0.5, y: 1 }, }), ], }), ); - // 기준 이미지는 상자 그대로(0..200), 자세 이미지는 축(100,100) 위로 400px - expect(reach).toEqual({ minX: 0, minY: -300, maxX: 200, maxY: 200 }); + // 기준 이미지는 상자 그대로(0..200), 자세 이미지는 바닥 기준점(100,200) 위로 400px + expect(reach).toEqual({ minX: 0, minY: -200, maxX: 200, maxY: 200 }); }); - it('원본 크기가 없는 자세는 box 배치로 세어 상자 밖으로 나가지 않는다', () => { + it('원본 크기가 없는 자세는 상자 배치로 세어 상자 밖으로 나가지 않는다', () => { const reach = computeSpriteReachAabb( makeSprite({ - imagePlacement: 'pivot', referenceNaturalSize: { source: 'base.png', width: 200, height: 200 }, poses: [makePose('p', {}, 'hand.png')], }), diff --git a/src/renderer/utils/sprite/spriteWireShape.test.ts b/src/renderer/utils/sprite/spriteWireShape.test.ts index b6b72f758..b070ed625 100644 --- a/src/renderer/utils/sprite/spriteWireShape.test.ts +++ b/src/renderer/utils/sprite/spriteWireShape.test.ts @@ -122,7 +122,7 @@ describe('toSpriteWireShape', () => { expect(shaped.className).toBeNull(); expect(shaped.useInlineStyles).toBeNull(); expect(shaped.zIndex).toBeNull(); - expect(shaped.imageFit).toBeNull(); + expect(shaped.referenceNaturalSize).toBeNull(); expect(shaped.groupId).toBe('group-1'); }); }); diff --git a/src/renderer/windows/overlay/App.spritePreview.test.tsx b/src/renderer/windows/overlay/App.spritePreview.test.tsx index 79751dce5..c2bd235d5 100644 --- a/src/renderer/windows/overlay/App.spritePreview.test.tsx +++ b/src/renderer/windows/overlay/App.spritePreview.test.tsx @@ -148,14 +148,11 @@ const spritePosition = (dx: number): CanonicalReactiveSpritePosition => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 100, height: 100 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [], transitionMs: 90, transitionEasing: 'linear', - imagePlacement: 'box', referenceNaturalSize: null, }); diff --git a/src/types/key/sprites.test.ts b/src/types/key/sprites.test.ts index 99edf91e5..776660d4d 100644 --- a/src/types/key/sprites.test.ts +++ b/src/types/key/sprites.test.ts @@ -18,8 +18,6 @@ const backendWireSprite = { hidden: false, id: '566b0333-494c-4d47-a76d-506b71e5ac4c', idleTransform: { rotation: 0, scale: 1, x: 0, y: 0 }, - imageFit: 'contain', - imageRect: { height: 200, width: 200, x: 0, y: 0 }, pivot: { x: 0.5, y: 0.5 }, poses: [], activation: 'whileHeld', @@ -68,8 +66,6 @@ describe('reactiveSpritePositionSchema wire 계약', () => { triggers: ['566b0333-494c-4d47-a76d-506b71e5ac4c'], transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, ...pose, }, @@ -155,26 +151,28 @@ describe('reactiveSpritePositionSchema wire 계약', () => { ).toBe(false); }); - it('imageRect가 백엔드 좌표·크기 상한을 넘으면 거부한다', () => { + it('상태 기준점은 생략·null·0..1 값을 허용하고 범위 밖은 거부한다', () => { + expect(reactiveSpritePositionSchema.safeParse(withPose({})).success).toBe( + true, + ); expect( - reactiveSpritePositionSchema.safeParse({ - ...backendWireSprite, - imageRect: { x: 40000, y: 0, width: 200, height: 200 }, - }).success, - ).toBe(false); + reactiveSpritePositionSchema.safeParse(withPose({ pivot: null })).success, + ).toBe(true); expect( - reactiveSpritePositionSchema.safeParse({ - ...backendWireSprite, - imageRect: { x: 0, y: 0, width: 40000, height: 200 }, - }).success, + reactiveSpritePositionSchema.safeParse( + withPose({ pivot: { x: 0, y: 1 } }), + ).success, + ).toBe(true); + expect( + reactiveSpritePositionInputSchema.safeParse( + withPose({ pivot: { x: -0.001, y: 0.5 } }), + ).success, ).toBe(false); }); }); describe('findDuplicateTriggerPose', () => { const pose = (poseId: string, triggers: string[]): SpritePose => ({ - contactPoint: { x: 0.5, y: 1 }, - imagePivot: null, imageOverrideMetrics: null, poseId, triggers, diff --git a/tests/fixtures/editor-ops-parity.json b/tests/fixtures/editor-ops-parity.json index f565f517a..fe7bc821d 100644 --- a/tests/fixtures/editor-ops-parity.json +++ b/tests/fixtures/editor-ops-parity.json @@ -3095,7 +3095,7 @@ }, { "name": "resizesprite-scale-with-setbounds-batch", - "comment": "resizeSprite는 bounds 교체 + 이전 bounds 대비 배율(sx=2, sy=0.5)로 imageRect·idleTransform·poses 오프셋을 스케일한다. rotation·scale·pivot·contactPoint 불변. 같은 배치의 다른 요소 setBounds와 원자 커밋", + "comment": "resizeSprite는 bounds 교체 + 이전 bounds 대비 배율(sx=2, sy=0.5)로 idleTransform·poses 오프셋을 스케일한다. rotation·scale·pivot 불변. 같은 배치의 다른 요소 setBounds와 원자 커밋", "initialDocument": { "schemaVersion": 1, "keys": { "4key": ["A"] }, @@ -3130,8 +3130,6 @@ "className": null, "useInlineStyles": null, "baseImage": null, - "imageFit": null, - "imageRect": { "x": 40, "y": -10, "width": 160, "height": 80 }, "pivot": { "x": 0.5, "y": 0.5 }, "idleTransform": { "x": 12, @@ -3149,8 +3147,7 @@ "rotation": -90, "scale": 0.5 }, - "imageOverride": null, - "contactPoint": { "x": 0.5, "y": 1 } + "imageOverride": null } ], "activation": "whileHeld", @@ -3208,8 +3205,6 @@ "className": null, "useInlineStyles": null, "baseImage": null, - "imageFit": null, - "imageRect": { "x": 80, "y": -5, "width": 320, "height": 40 }, "pivot": { "x": 0.5, "y": 0.5 }, "idleTransform": { "x": 24, @@ -3227,8 +3222,7 @@ "rotation": -90, "scale": 0.5 }, - "imageOverride": null, - "contactPoint": { "x": 0.5, "y": 1 } + "imageOverride": null } ], "activation": "whileHeld", @@ -3277,8 +3271,6 @@ "className": null, "useInlineStyles": null, "baseImage": null, - "imageFit": null, - "imageRect": { "x": 40, "y": -10, "width": 160, "height": 80 }, "pivot": { "x": 0.5, "y": 0.5 }, "idleTransform": { "x": 12, @@ -3296,8 +3288,7 @@ "rotation": -90, "scale": 0.5 }, - "imageOverride": null, - "contactPoint": { "x": 0.5, "y": 1 } + "imageOverride": null } ], "activation": "whileHeld", @@ -3349,8 +3340,6 @@ "className": null, "useInlineStyles": null, "baseImage": null, - "imageFit": null, - "imageRect": { "x": 40, "y": -10, "width": 160, "height": 80 }, "pivot": { "x": 0.5, "y": 0.5 }, "idleTransform": { "x": 12, @@ -3368,8 +3357,7 @@ "rotation": -90, "scale": 0.5 }, - "imageOverride": null, - "contactPoint": { "x": 0.5, "y": 1 } + "imageOverride": null } ], "activation": "whileHeld", diff --git a/tests/fixtures/sprite-constraint-parity.json b/tests/fixtures/sprite-constraint-parity.json index f213eaf4d..d280a6499 100644 --- a/tests/fixtures/sprite-constraint-parity.json +++ b/tests/fixtures/sprite-constraint-parity.json @@ -19,12 +19,6 @@ "min": 0, "max": 1000 }, - "imageRect": { - "coordMin": -32768, - "coordMax": 32768, - "dimensionMax": 32768 - }, - "resizeMinDimension": 1e-06, "maxPoses": 64, "maxTriggersPerPose": 512, "pressDurationMs": { @@ -35,13 +29,8 @@ "defaultTransitionEasing": "cubic-bezier(0.4, 0, 0.2, 1)", "defaultActivation": "whileHeld", "defaultPressDurationMs": 300, - "defaultContactPoint": { - "x": 0.5, - "y": 1 - }, "imageMetrics": { "dimensionMin": 1, "dimensionMax": 32768 - }, - "defaultImagePlacement": "box" + } } diff --git a/tests/fixtures/sprite-resize-scale-parity.json b/tests/fixtures/sprite-resize-scale-parity.json index 43dfad453..036346acd 100644 --- a/tests/fixtures/sprite-resize-scale-parity.json +++ b/tests/fixtures/sprite-resize-scale-parity.json @@ -1,10 +1,11 @@ { "version": 1, - "comment": "resizeSprite 스케일 수학 파리티 fixture. ratio = next/prev (무효·비유한은 1, 언더플로 0 허용), 값 적용 = clamp(value * ratio, min, max), clamp는 max(min)→min(max) 순서. 필드 범위: offset ±2000, coord ±32768, dimension [0.000001, 32768]. 소비자: tests/sprite-resize-scale-parity.test.ts, src-tauri Rust 테스트. expectedHex는 IEEE754 f64 big-endian 비트 - 양측이 to_bits로 대조한다", + "comment": "resizeSprite 스케일 수학 파리티 fixture. ratio = next/prev (무효·비유한은 1, 언더플로 0 허용), 값 적용 = clamp(value * ratio, min, max), clamp는 max(min)→min(max) 순서. 스케일 대상은 idleTransform·자세 transform의 x/y(offset ±2000)뿐이다. 소비자: tests/sprite-resize-scale-parity.test.ts, src-tauri Rust 테스트. expectedHex는 IEEE754 f64 big-endian 비트 - 양측이 to_bits로 대조한다", "ranges": { - "offset": [-2000, 2000], - "coord": [-32768, 32768], - "dimension": [0.000001, 32768] + "offset": [ + -2000, + 2000 + ] }, "cases": [ { @@ -70,60 +71,6 @@ "expected": 0, "expectedHex": "0x0000000000000000" }, - { - "name": "coord-nonuniform", - "field": "coord", - "prev": 320, - "next": 200, - "value": -40.5, - "expected": -25.3125, - "expectedHex": "0xc039500000000000" - }, - { - "name": "coord-clamp-min", - "field": "coord", - "prev": 10, - "next": 32768, - "value": -1000, - "expected": -32768, - "expectedHex": "0xc0e0000000000000" - }, - { - "name": "dimension-basic", - "field": "dimension", - "prev": 200, - "next": 150, - "value": 180, - "expected": 135, - "expectedHex": "0x4060e00000000000" - }, - { - "name": "dimension-floor-underflow", - "field": "dimension", - "prev": 1000, - "next": 1e-9, - "value": 5, - "expected": 0.000001, - "expectedHex": "0x3eb0c6f7a0b5ed8d" - }, - { - "name": "dimension-clamp-max", - "field": "dimension", - "prev": 10, - "next": 32768, - "value": 100, - "expected": 32768, - "expectedHex": "0x40e0000000000000" - }, - { - "name": "dimension-ratio-one-passthrough-below-floor", - "field": "dimension", - "prev": 200, - "next": 200, - "value": 1e-7, - "expected": 1e-7, - "expectedHex": "0x3e7ad7f29abcaf48" - }, { "name": "offset-ratio-one-passthrough-beyond-max", "field": "offset", diff --git a/tests/sprite-constraint-parity.test.ts b/tests/sprite-constraint-parity.test.ts index 9e1ccb69c..f7876e5c0 100644 --- a/tests/sprite-constraint-parity.test.ts +++ b/tests/sprite-constraint-parity.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_SPRITE_ACTIVATION, - DEFAULT_SPRITE_CONTACT_POINT, DEFAULT_SPRITE_PRESS_DURATION_MS, DEFAULT_SPRITE_TRANSITION_EASING, DEFAULT_SPRITE_TRANSITION_MS, @@ -12,7 +11,7 @@ import { reactiveSpritePositionInputSchema, reactiveSpritePositionSchema, spriteAnchorSchema, - spriteRectSchema, + spriteImageMetricsSchema, spriteTransformSchema, } from '../src/types/key/sprites'; @@ -31,8 +30,6 @@ interface SpriteConstraintFixture { scale: { min: number; max: number }; anchor: { min: number; max: number }; transitionMs: { min: number; max: number }; - imageRect: { coordMin: number; coordMax: number; dimensionMax: number }; - resizeMinDimension: number; maxPoses: number; maxTriggersPerPose: number; pressDurationMs: { min: number; max: number }; @@ -40,7 +37,7 @@ interface SpriteConstraintFixture { defaultTransitionEasing: string; defaultActivation: string; defaultPressDurationMs: number; - defaultContactPoint: { x: number; y: number }; + imageMetrics: { dimensionMin: number; dimensionMax: number }; } const fixture = JSON.parse( @@ -58,8 +55,6 @@ const baseSprite = () => ({ className: null, useInlineStyles: null, baseImage: null, - imageFit: null, - imageRect: { x: 0, y: 0, width: 200, height: 200 }, pivot: { x: 0.5, y: 0.5 }, idleTransform: { x: 0, y: 0, rotation: 0, scale: 1 }, poses: [] as unknown[], @@ -67,6 +62,7 @@ const baseSprite = () => ({ pressDurationMs: 300, transitionMs: 90, transitionEasing: 'cubic-bezier(0.4, 0, 0.2, 1)', + referenceNaturalSize: null, }); const pose = (poseId: string, triggers: string[]) => ({ @@ -74,7 +70,7 @@ const pose = (poseId: string, triggers: string[]) => ({ triggers, transform: { x: 0, y: 0, rotation: 0, scale: 1 }, imageOverride: null, - contactPoint: { x: 0.5, y: 1 }, + imageOverrideMetrics: null, }); describe('sprite constraint parity (fixture)', () => { @@ -96,10 +92,7 @@ describe('sprite constraint parity (fixture)', () => { expect(SPRITE_CONSTRAINTS.scale).toEqual(fixture.scale); expect(SPRITE_CONSTRAINTS.anchor).toEqual(fixture.anchor); expect(SPRITE_CONSTRAINTS.transitionMs).toEqual(fixture.transitionMs); - expect(SPRITE_CONSTRAINTS.imageRect).toEqual(fixture.imageRect); - expect(SPRITE_CONSTRAINTS.resizeMinDimension).toBe( - fixture.resizeMinDimension, - ); + expect(SPRITE_CONSTRAINTS.imageMetrics).toEqual(fixture.imageMetrics); expect(SPRITE_CONSTRAINTS.maxPoses).toBe(fixture.maxPoses); expect(SPRITE_CONSTRAINTS.maxTriggersPerPose).toBe( fixture.maxTriggersPerPose, @@ -116,7 +109,6 @@ describe('sprite constraint parity (fixture)', () => { expect(DEFAULT_SPRITE_PRESS_DURATION_MS).toBe( fixture.defaultPressDurationMs, ); - expect(DEFAULT_SPRITE_CONTACT_POINT).toEqual(fixture.defaultContactPoint); }); it('transform 스칼라는 fixture 경계에서 통과하고 근소 초과에서 거부된다', () => { @@ -145,7 +137,7 @@ describe('sprite constraint parity (fixture)', () => { } }); - it('pivot·imageRect·transitionMs도 fixture 경계를 그대로 강제한다', () => { + it('pivot·원본 크기·transitionMs도 fixture 경계를 그대로 강제한다', () => { expect( spriteAnchorSchema.safeParse({ x: fixture.anchor.max, y: 0 }).success, ).toBe(true); @@ -154,26 +146,28 @@ describe('sprite constraint parity (fixture)', () => { .success, ).toBe(false); - const rect = { x: 0, y: 0, width: 200, height: 200 }; + const metrics = { source: 'a.png', width: 100, height: 100 }; expect( - spriteRectSchema.safeParse({ ...rect, x: fixture.imageRect.coordMax }) - .success, + spriteImageMetricsSchema.safeParse({ + ...metrics, + width: fixture.imageMetrics.dimensionMax, + }).success, ).toBe(true); expect( - spriteRectSchema.safeParse({ ...rect, x: fixture.imageRect.coordMax + 1 }) - .success, + spriteImageMetricsSchema.safeParse({ + ...metrics, + width: fixture.imageMetrics.dimensionMax + 1, + }).success, ).toBe(false); expect( - spriteRectSchema.safeParse({ - ...rect, - width: fixture.imageRect.dimensionMax, + spriteImageMetricsSchema.safeParse({ + ...metrics, + height: fixture.imageMetrics.dimensionMin - 1, }).success, - ).toBe(true); + ).toBe(false); + // Rust u32 계약 - 소수 픽셀은 거부 expect( - spriteRectSchema.safeParse({ - ...rect, - width: fixture.imageRect.dimensionMax + 1, - }).success, + spriteImageMetricsSchema.safeParse({ ...metrics, width: 100.5 }).success, ).toBe(false); expect( @@ -220,14 +214,34 @@ describe('sprite constraint parity (fixture)', () => { pressDurationMs: _pressDurationMs, ...legacySprite } = baseSprite(); - const { contactPoint: _contactPoint, ...legacyPose } = pose('p', ['t']); - const legacy = { ...legacySprite, poses: [legacyPose] }; + const legacy = { ...legacySprite, poses: [pose('p', ['t'])] }; expect(reactiveSpritePositionSchema.safeParse(legacy).success).toBe(false); expect(reactiveSpritePositionInputSchema.safeParse(legacy).success).toBe( true, ); }); + it('제거된 필드(imageRect·imageFit·imagePlacement·contactPoint·imagePivot)는 무시된다', () => { + const legacy = { + ...baseSprite(), + imageFit: 'contain', + imageRect: { x: 10, y: 10, width: 100, height: 100 }, + imagePlacement: 'box', + poses: [ + { + ...pose('p', ['t']), + contactPoint: { x: 0.5, y: 1 }, + imagePivot: null, + }, + ], + }; + const parsed = reactiveSpritePositionSchema.safeParse(legacy); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect('imageRect' in parsed.data).toBe(false); + expect('contactPoint' in parsed.data.poses[0]).toBe(false); + }); + it('input 스키마 컬렉션 상한은 fixture 값에서 통과하고 +1에서 거부된다', () => { const triggersAtCap = Array.from( { length: fixture.maxTriggersPerPose }, diff --git a/tests/sprite-resize-scale-parity.test.ts b/tests/sprite-resize-scale-parity.test.ts index 5a2058698..ceb167a6e 100644 --- a/tests/sprite-resize-scale-parity.test.ts +++ b/tests/sprite-resize-scale-parity.test.ts @@ -4,9 +4,8 @@ import { describe, expect, it } from 'vitest'; import { SPRITE_CONSTRAINTS } from '../src/types/key/sprites'; import { - scaleSpriteResizeValue, + scaleSpriteResizeOffset, spriteResizeRatio, - type SpriteResizeValueField, } from '../src/renderer/utils/sprite/resizeProjection'; // Rust 적용기와 같은 fixture를 공유해 resizeSprite 스케일 수학의 f64 결과를 @@ -20,7 +19,7 @@ const FIXTURE_PATH = join( interface ResizeScaleCase { name: string; - field: SpriteResizeValueField | 'ratio'; + field: 'offset' | 'ratio'; prev: number; next: number; value?: number; @@ -30,7 +29,7 @@ interface ResizeScaleCase { interface ResizeScaleFixture { version: number; - ranges: Record; + ranges: { offset: [number, number] }; cases: ResizeScaleCase[]; } @@ -55,21 +54,11 @@ describe('sprite resize scale parity (fixture)', () => { SPRITE_CONSTRAINTS.offset.min, SPRITE_CONSTRAINTS.offset.max, ]); - expect(fixture.ranges.coord).toEqual([ - SPRITE_CONSTRAINTS.imageRect.coordMin, - SPRITE_CONSTRAINTS.imageRect.coordMax, - ]); - expect(fixture.ranges.dimension).toEqual([ - SPRITE_CONSTRAINTS.resizeMinDimension, - SPRITE_CONSTRAINTS.imageRect.dimensionMax, - ]); }); it('케이스는 비어 있지 않고 필드 종류를 전수 커버한다', () => { const fields = new Set(fixture.cases.map((entry) => entry.field)); - expect([...fields].sort()).toEqual( - ['coord', 'dimension', 'offset', 'ratio'].sort(), - ); + expect([...fields].sort()).toEqual(['offset', 'ratio']); }); it.each(fixture.cases.map((entry) => [entry.name, entry] as const))( @@ -79,7 +68,7 @@ describe('sprite resize scale parity (fixture)', () => { const actual = entry.field === 'ratio' ? ratio - : scaleSpriteResizeValue(entry.value ?? 0, ratio, entry.field); + : scaleSpriteResizeOffset(entry.value ?? 0, ratio); expect(bitsHex(actual)).toBe(entry.expectedHex); }, ); From ad0d857a8bf871ac4df2a9c1a5fd1c529faf5b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Thu, 3 Sep 2026 21:30:04 +0900 Subject: [PATCH 094/125] =?UTF-8?q?docs:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=EC=A4=80=EC=A0=90=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20API=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/editor/page.mdx | 54 +++++++++++-------- docs/content/en/custom-css/variables/page.mdx | 11 ++-- docs/content/ko/api-reference/editor/page.mdx | 51 +++++++++++------- docs/content/ko/custom-css/variables/page.mdx | 9 ++-- 4 files changed, 70 insertions(+), 55 deletions(-) diff --git a/docs/content/en/api-reference/editor/page.mdx b/docs/content/en/api-reference/editor/page.mdx index f04022068..27e54cbfe 100644 --- a/docs/content/en/api-reference/editor/page.mdx +++ b/docs/content/en/api-reference/editor/page.mdx @@ -84,7 +84,7 @@ type SpritePositions = Record; interface ReactiveSpritePosition { id?: string; // stable element identity (UUID) - dx: number; // activity area origin and size; feeds overlay window sizing + dx: number; // element box origin and size. The box is the base image box and feeds overlay window sizing dy: number; width: number; height: number; @@ -94,18 +94,19 @@ interface ReactiveSpritePosition { groupId?: string | null; // omitted by the backend when unset className: string | null; useInlineStyles: boolean | null; - baseImage: string | null; // image shown when no pose matches - imageFit: 'cover' | 'contain' | 'fill' | null; // rendering treats absent as 'contain' - imageRect: { x: number; y: number; width: number; height: number }; // image box inside the activity area, coordinates within ±32768 - pivot: { x: number; y: number }; // normalized transform origin, 0–1 each + baseImage: string | null; // image shown when no pose matches; always fills the element box + referenceNaturalSize: { + source: string | null; + width: number; + height: number; + } | null; // intrinsic pixel size that defines the pixel scale (integers, 1–32768). source must equal baseImage when one is set and be null otherwise; null falls back to drawing every image in the element box + pivot: { x: number; y: number }; // normalized shared axis, 0–1 each, default {0.5, 0.5}. Base-image pivot and transform origin for every pose idleTransform: SpriteTransform; // base transform when no pose matches poses: SpritePose[]; // commits accept at most 64 activation: 'whileHeld' | 'onPress'; // default whileHeld. onPress plays the pose once on key-down, then returns to idle pressDurationMs: number; // onPress playback duration, 1–5000, default 300 transitionMs: number; // pose transition duration, 0–1000, default 0. Ignored by onPress, which plays for pressDurationMs transitionEasing: string; // CSS easing, default 'cubic-bezier(0.4, 0, 0.2, 1)' - imagePlacement: 'box' | 'pivot'; // image placement, default box. pivot aligns each image's own anchor to the pivot point and draws it at the reference image's pixel scale - referenceNaturalSize: { source: string | null; width: number; height: number } | null; // pixel-scale reference for pivot placement. source must equal baseImage when one is set, and be null otherwise } interface SpritePose { @@ -113,10 +114,13 @@ interface SpritePose { name?: string | null; // omitted by the backend when unset triggers: string[]; // key element ids; commits accept at most 512, sorted and deduplicated transform: SpriteTransform; + pivot?: { x: number; y: number } | null; // independent pivot within the pose image; null or omission follows the sprite's shared pivot imageOverride: string | null; // replaces baseImage while this pose is active - contactPoint: { x: number; y: number }; // normalized key-contact pin of the pose image, 0–1 each, default {0.5, 1}. Editor helper only; runtime resolution ignores it - imagePivot: { x: number; y: number } | null; // anchor of this pose image under pivot placement, 0–1 each. null inherits the sprite pivot; normalized to null when imageOverride is absent - imageOverrideMetrics: { source: string; width: number; height: number } | null; // intrinsic pixel size of imageOverride (integers, 1–32768). Required under pivot placement when an override is set, and source must equal the path exactly + imageOverrideMetrics: { + source: string; + width: number; + height: number; + } | null; // intrinsic pixel size of imageOverride (integers, 1–32768). Optional; when set, source must equal the path exactly. Normalized to null when imageOverride is absent } interface SpriteTransform { @@ -136,18 +140,24 @@ Poses with two or more triggers never take part in blending, so a sprite whose held keys only appear in such poses returns to idle. Two poses of one sprite may not share the same trigger set, and commits reject empty trigger lists. -Under `pivot` placement every image puts its own anchor on the pivot point P -(imageRect origin + pivot × size) and is drawn at intrinsic pixels × the -reference scale. The reference scale divides imageRect by referenceNaturalSize: -contain takes the smaller ratio, cover the larger, fill uses each axis. When -poses blend, anchors are never averaged; the pose whose image was chosen supplies -its anchor. Under pivot placement a missing or mismatched intrinsic size rejects -the commit with `VALIDATION_FAILED` and a `details.validationCode` of -`MISSING_SPRITE_IMAGE_METRICS`, `STALE_SPRITE_IMAGE_METRICS`, or -`INVALID_SPRITE_IMAGE_METRICS`. A schemaVersion 1 patch that omits -`imagePlacement`, `referenceNaturalSize`, `imagePivot`, `imageOverrideMetrics`, -`contactPoint`, `activation`, or `pressDurationMs` on an existing element or -pose keeps the stored value. +The element box is the base-image box. The base image always fills it, and the +shared axis P is the sprite pivot × (width, height). A pose whose pivot is null +or omitted uses the same normalized point. A pose with a pivot value aligns that +independent point within its image to P. A pose image with a different intrinsic +size is drawn at intrinsic pixels × the reference scale, where the reference +scale is (width, height) ÷ referenceNaturalSize per axis. Without a reference +size or the pose image's own metrics, the image uses the element-box size. +Moving the shared pivot keeps the base image in place while linked poses follow +the new axis. Poses with an independent pivot keep their screen position by +compensating x/y in the same commit. Moving a pose pivot or switching the link +also compensates x/y to avoid a sudden image jump. An out-of-range pivot +rejects the commit with `VALIDATION_FAILED` and a `details.validationCode` of +`INVALID_SPRITE_PIVOT` or `INVALID_SPRITE_POSE_PIVOT`. Invalid image metrics use +`STALE_SPRITE_IMAGE_METRICS` or `INVALID_SPRITE_IMAGE_METRICS`. A schemaVersion 1 +patch that omits `pivot`, `referenceNaturalSize`, `imageOverrideMetrics`, +`activation`, or `pressDurationMs` on an existing element or pose keeps the +stored value. Sending null for a pose pivot links it to the shared pivot. +Changing `baseImage` or `imageOverride` while omitting its metrics clears them. ## Read the Current Document diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index 98a10fae8..c39bf1bd3 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -195,21 +195,18 @@ you can replace. Disable **Inline Styles Priority** on the sprite to use them. | Variable | Description | Type | Example | | --------------------- | -------------------------------------------------------------------------------------- | -------------- | -------------------------- | -| `--sprite-fit` | Replacement fit for the sprite image | `` | `cover`, `contain` | | `--sprite-transform` | Replacement transform for the resolved pose (an onPress sprite then only swaps images) | `` | `rotate(15deg) scale(1.2)` | | `--sprite-transition` | Replacement transition for the pose change (whileHeld only) | `` | `transform 120ms ease-out` | ```css .left-hand [data-sprite-element] > img { - --sprite-fit: cover; --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1); } ``` -`--sprite-fit` only matters under box placement. Under anchor-based placement the -image element already has the source aspect ratio, so contain and cover do not -change the result; overriding fill, or the element size itself, voids the -anchor and fingertip alignment guarantees. +The image element already has the source aspect ratio and fills its box, so +`object-fit` has nothing to change. Overriding the element size itself voids the +pivot alignment of pose images. ## Selector Reference @@ -254,7 +251,7 @@ anchor and fingertip alignment guarantees. | Selector | Description | | ------------------------------------ | --------------------------------------------------------------- | -| `[data-sprite-element]` | Every sprite activity area | +| `[data-sprite-element]` | Every sprite element box | | `[data-sprite-element] > img` | The sprite image layer | | `[data-sprite-state="inactive"]` | Sprites with no trigger key held | | `[data-sprite-state="active"]` | Sprites with a trigger key held (onPress sprites stay inactive) | diff --git a/docs/content/ko/api-reference/editor/page.mdx b/docs/content/ko/api-reference/editor/page.mdx index d8e3f4b9f..cde2b335a 100644 --- a/docs/content/ko/api-reference/editor/page.mdx +++ b/docs/content/ko/api-reference/editor/page.mdx @@ -80,7 +80,7 @@ type SpritePositions = Record; interface ReactiveSpritePosition { id?: string; // 안정 요소 신원 (UUID) - dx: number; // 활동 영역 원점과 크기, 오버레이 창 크기 계산에 들어갑니다 + dx: number; // 요소 상자 원점과 크기. 상자가 곧 기본 이미지 상자이고 오버레이 창 크기 계산에 들어갑니다 dy: number; width: number; height: number; @@ -90,18 +90,19 @@ interface ReactiveSpritePosition { groupId?: string | null; // 미설정이면 백엔드가 키를 생략합니다 className: string | null; useInlineStyles: boolean | null; - baseImage: string | null; // 일치하는 자세가 없을 때 보여줄 이미지 - imageFit: 'cover' | 'contain' | 'fill' | null; // 렌더링은 부재를 'contain'으로 취급합니다 - imageRect: { x: number; y: number; width: number; height: number }; // 활동 영역 안 이미지 박스, 좌표는 ±32768 이내 - pivot: { x: number; y: number }; // 정규화 변환 원점, 각각 0–1 + baseImage: string | null; // 일치하는 자세가 없을 때 보여줄 이미지. 항상 요소 상자를 채웁니다 + referenceNaturalSize: { + source: string | null; + width: number; + height: number; + } | null; // 픽셀 배율의 기준이 되는 원본 크기(정수, 1–32768). baseImage가 있으면 source가 그 경로와 같아야 하고 없으면 null이어야 합니다. null이면 모든 이미지를 요소 상자에 그대로 그립니다 + pivot: { x: number; y: number }; // 정규화 공통 축, 각각 0–1, 기본 {0.5, 0.5}. 기본 이미지의 기준점이자 모든 자세 변환의 축 idleTransform: SpriteTransform; // 일치하는 자세가 없을 때의 기본 변환 poses: SpritePose[]; // 커밋은 최대 64개까지 허용 activation: 'whileHeld' | 'onPress'; // 기본 whileHeld. onPress는 누른 순간 자세를 한 번 재생하고 idle로 복귀 pressDurationMs: number; // onPress 재생 시간, 1–5000, 기본 300 transitionMs: number; // 자세 전환 시간, 0–1000, 기본 0. onPress는 무시하고 pressDurationMs 동안 재생 transitionEasing: string; // CSS easing, 기본 'cubic-bezier(0.4, 0, 0.2, 1)' - imagePlacement: 'box' | 'pivot'; // 이미지 배치 방식, 기본 box. pivot은 이미지마다 자기 축을 기준점에 맞추고 기준 이미지의 픽셀 배율로 그립니다 - referenceNaturalSize: { source: string | null; width: number; height: number } | null; // pivot 배치의 픽셀 배율 기준. baseImage가 있으면 source가 그 경로와 같아야 하고, 없으면 null이어야 합니다 } interface SpritePose { @@ -109,10 +110,13 @@ interface SpritePose { name?: string | null; // 미설정이면 백엔드가 키를 생략합니다 triggers: string[]; // 키 요소 id 목록, 커밋은 최대 512개까지 허용하며 정렬·중복 제거 transform: SpriteTransform; + pivot?: { x: number; y: number } | null; // 상태 이미지의 독립 기준점. null 또는 생략이면 스프라이트의 공통 기준점을 따라갑니다 imageOverride: string | null; // 이 자세가 활성인 동안 baseImage를 대체 - contactPoint: { x: number; y: number }; // 자세 이미지의 키 접점(핀), 정규화 0–1, 기본 {0.5, 1}. 에디터 도우미 전용이고 런타임 해석에는 쓰이지 않습니다 - imagePivot: { x: number; y: number } | null; // pivot 배치에서 이 자세 이미지의 축, 정규화 0–1. null이면 스프라이트 pivot을 상속하고, imageOverride가 없으면 null로 정규화됩니다 - imageOverrideMetrics: { source: string; width: number; height: number } | null; // imageOverride의 원본 픽셀 크기(정수, 1–32768). pivot 배치에서 override가 있으면 필수이고 source가 경로와 정확히 같아야 합니다 + imageOverrideMetrics: { + source: string; + width: number; + height: number; + } | null; // imageOverride의 원본 픽셀 크기(정수, 1–32768). 선택 사항이고, 있으면 source가 경로와 정확히 같아야 합니다. imageOverride가 없으면 null로 정규화됩니다 } interface SpriteTransform { @@ -131,16 +135,23 @@ interface SpriteTransform { 눌린 키가 그런 자세에만 있으면 idle로 돌아갑니다. 한 스프라이트의 두 자세가 같은 트리거 집합을 가질 수 없고, 빈 트리거 목록은 커밋에서 거부됩니다. -배치 방식이 `pivot`이면 이미지마다 축을 기준점 P(imageRect 원점 + pivot × 크기)에 -놓고, 크기는 원본 픽셀 × 기준 배율입니다. 기준 배율은 imageRect를 -referenceNaturalSize로 나눈 값으로 contain은 작은 쪽, cover는 큰 쪽, fill은 축별 -배율입니다. 자세를 섞을 때 축은 평균하지 않고 이미지를 고른 자세의 축을 씁니다. -pivot 배치에서 원본 크기가 없거나 경로와 어긋나면 커밋이 `VALIDATION_FAILED`로 -거절되며 `details.validationCode`는 `MISSING_SPRITE_IMAGE_METRICS`, -`STALE_SPRITE_IMAGE_METRICS`, `INVALID_SPRITE_IMAGE_METRICS` 중 하나입니다. -schemaVersion 1 패치에서 이미 있는 요소·자세의 `imagePlacement`, -`referenceNaturalSize`, `imagePivot`, `imageOverrideMetrics`, `contactPoint`, -`activation`, `pressDurationMs`를 생략하면 저장된 값이 유지됩니다. +요소 상자가 곧 기본 이미지 상자입니다. 기본 이미지는 항상 상자를 채우고, 공통 축 P는 +스프라이트의 pivot × (width, height)입니다. 상태의 pivot이 null이거나 생략되면 상태 +이미지도 같은 정규화 기준점을 사용합니다. 상태에 pivot 값이 있으면 그 상태 이미지의 +독립 기준점을 P에 맞춥니다. 원본 크기가 다른 상태 이미지는 원본 픽셀 × 기준 배율로 +그립니다. 기준 배율은 (width, height)를 referenceNaturalSize로 축별로 나눈 값입니다. +기준 크기나 상태 이미지의 크기가 없으면 그 이미지를 요소 상자 크기로 그립니다. +에디터에서 공통 기준점을 옮기면 기본 이미지는 제자리에 남고, 공통 기준점에 연결된 +상태는 새 축을 따라갑니다. 독립 기준점을 가진 상태는 x/y를 같은 커밋에서 보정해 +화면 위치를 유지합니다. 상태 기준점을 옮기거나 연결을 전환할 때도 상태 이미지가 +갑자기 움직이지 않도록 x/y를 보정합니다. 범위를 벗어난 기준점은 커밋이 +`VALIDATION_FAILED`로 거절되며 `details.validationCode`는 `INVALID_SPRITE_PIVOT` +또는 `INVALID_SPRITE_POSE_PIVOT`입니다. 원본 크기가 경로와 어긋나거나 범위를 벗어나면 +`STALE_SPRITE_IMAGE_METRICS` 또는 `INVALID_SPRITE_IMAGE_METRICS`입니다. +schemaVersion 1 패치에서 이미 있는 요소·자세의 `pivot`, `referenceNaturalSize`, +`imageOverrideMetrics`, `activation`, `pressDurationMs`를 생략하면 저장된 값이 +유지됩니다. 상태의 `pivot`을 null로 보내면 공통 기준점 연결로 바뀝니다. 다만 +`baseImage`나 `imageOverride`를 바꾸면서 크기를 생략하면 크기는 비워집니다. ## 현재 문서 조회 diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx index c476cfcf8..53f75a385 100644 --- a/docs/content/ko/custom-css/variables/page.mdx +++ b/docs/content/ko/custom-css/variables/page.mdx @@ -190,20 +190,17 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 | 변수 | 설명 | 타입 | 예시 | | --------------------- | ------------------------------------------------- | -------------- | -------------------------- | -| `--sprite-fit` | 스프라이트 이미지 맞춤 교체 | `` | `cover`, `contain` | | `--sprite-transform` | 해석된 자세의 변환 교체 (onPress는 이미지만 바뀜) | `` | `rotate(15deg) scale(1.2)` | | `--sprite-transition` | 자세 전환 트랜지션 교체 (whileHeld에만 적용) | `` | `transform 120ms ease-out` | ```css .왼손 [data-sprite-element] > img { - --sprite-fit: cover; --sprite-transition: transform 90ms cubic-bezier(0.2, 0, 0, 1); } ``` -`--sprite-fit`은 상자 맞춤 배치에서만 뜻이 있습니다. 축 기준 배치는 이미지 요소가 -원본 비율 그대로라 contain·cover가 결과를 바꾸지 않고, 늘리기(fill)에서 바꾸거나 -크기 자체를 CSS로 덮으면 축·손끝 정합은 보장하지 않습니다. +이미지 요소는 원본 비율 그대로 상자를 채우므로 `object-fit`이 바꿀 것이 없습니다. +크기 자체를 CSS로 덮으면 자세 이미지의 기준점 정합은 보장하지 않습니다. ## 선택자 레퍼런스 @@ -248,7 +245,7 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 | 선택자 | 설명 | | ----------------------------------------- | ----------------------------------------------------- | -| `[data-sprite-element]` | 모든 스프라이트 활동 영역 | +| `[data-sprite-element]` | 모든 스프라이트 요소 상자 | | `[data-sprite-element] > img` | 스프라이트 이미지 층 | | `[data-sprite-state="inactive"]` | 트리거 키를 누르지 않은 스프라이트 | | `[data-sprite-state="active"]` | 트리거 키를 누른 스프라이트 (onPress는 항상 inactive) | From c4c4dcd4631ccf69a1d9c7081458c39a6c513384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Fri, 4 Sep 2026 10:48:16 +0900 Subject: [PATCH 095/125] =?UTF-8?q?fix:=20=EC=83=81=ED=83=9C=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=EA=B8=B0=EC=A4=80=EC=A0=90=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=EC=9D=84=20=EC=84=A4=EC=A0=95=20=EC=B9=B4=EB=93=9C=20=EB=AC=B8?= =?UTF-8?q?=EB=B2=95=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.test.tsx | 2 +- .../single/SpritePoseEditorPopup.tsx | 27 ++++++++++--------- src/renderer/locales/en.json | 2 +- src/renderer/locales/ko.json | 2 +- src/renderer/locales/ru.json | 2 +- src/renderer/locales/zh-Hant.json | 2 +- src/renderer/locales/zh-cn.json | 2 +- 7 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index c10bd319b..c6b811b82 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -445,7 +445,7 @@ describe('SingleSpritePanel 자세 편집', () => { expect(posePopup()!.getAttribute('aria-label')).toBe( 'propertiesPanel.spritePose 1', ); - expect(posePopup()!.dataset.fallbackHeight).toBe('240'); + expect(posePopup()!.dataset.fallbackHeight).toBe('229'); // 같은 행 재클릭 - 토글 닫힘 act(() => poseEditButtons()[0].click()); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx index cb239fc02..6502439f9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SpritePoseEditorPopup.tsx @@ -1,5 +1,6 @@ import React from 'react'; import PickerSurface from '@components/main/Grid/PropertiesPanel/PickerSurface'; +import { PropertySection } from '@components/main/Grid/PropertiesPanel/PropertyInputs'; import Dropdown from '@components/main/common/Dropdown'; import Checkbox from '@components/main/common/Checkbox'; import { NumberInput } from '@components/main/common/NumberInput'; @@ -12,6 +13,7 @@ import { type SpriteAnchor, type SpriteTransform, } from '@src/types/key/sprites'; +import { FORM_LABEL_CLASS, FORM_ROW_CLASS } from '@utils/cardRecipes'; import { clamp } from '@utils/core/clamp'; import SpriteImagePreviewCard from './SpriteImagePreviewCard'; @@ -98,8 +100,9 @@ const SpritePoseEditorPopup: React.FC = ({ referenceRef={referenceRef} panelElement={panelElement} fallbackWidth={172} - // 미리보기 76 + 변환 그리드 68 + 담당 키 32 + 간격·패딩 32 - fallbackHeight={followsBasePivot ? 240 : 276} + // 미리보기 76 + 변환 그리드 50 + 기준점 카드 40(입력 행 열리면 72) + // + 담당 키 23 + 간격·패딩 40 + fallbackHeight={followsBasePivot ? 229 : 261} cardClassName="flex flex-col p-[8px] gap-[8px] w-[172px] rounded-popup" offsetY={-93} interactiveRefs={interactiveRefs} @@ -199,24 +202,24 @@ const SpritePoseEditorPopup: React.FC = ({
-
-
- - {t('propertiesPanel.spriteFollowBasePivot') || - '기본 기준점 따라가기'} - + {/* 기준점 - 기본을 그대로 쓰거나 이 상태만의 축을 둔다. 토글은 이미지 피커의 + 설정 카드와 같은 행 문법, X·Y 쌍은 라벨을 옆에 둘 폭이 없어 한 행을 통째로 쓴다 */} + +
+

+ {t('propertiesPanel.spriteFollowBasePivot') || '기본 기준점 사용'} +

{!followsBasePivot ? ( -
+
@@ -267,7 +270,7 @@ const SpritePoseEditorPopup: React.FC = ({ />
) : null} -
+
{/* 담당 키 - 미리보기·변환 아래가 정위치, 신규 상태도 카드가 작아 한눈에 보인다 */} {poseControls.keyOptions.length === 0 && deadTriggers.length === 0 ? ( diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 4ae5b3a41..12ed5b98b 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -556,7 +556,7 @@ "spriteImageSelect": "Select", "spritePivot": "Pivot", "spritePivotHint": "Drag: move the pivot · Ctrl/⌘: no snapping", - "spriteFollowBasePivot": "Follow Base Pivot", + "spriteFollowBasePivot": "Use Base Pivot", "spriteStatePivot": "State Pivot", "spriteRotation": "Rotation", "spriteScale": "Scale", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index ebbbf6f77..108b36430 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -556,7 +556,7 @@ "spriteImageSelect": "선택", "spritePivot": "기준점", "spritePivotHint": "드래그: 기준점 이동 · Ctrl/⌘: 스냅 해제", - "spriteFollowBasePivot": "기본 기준점 따라가기", + "spriteFollowBasePivot": "기본 기준점 사용", "spriteStatePivot": "상태 기준점", "spriteRotation": "회전", "spriteScale": "배율", diff --git a/src/renderer/locales/ru.json b/src/renderer/locales/ru.json index f65692f61..e863340c1 100644 --- a/src/renderer/locales/ru.json +++ b/src/renderer/locales/ru.json @@ -561,7 +561,7 @@ "spriteImageSelect": "Выбрать", "spritePivot": "Точка опоры", "spritePivotHint": "Перетаскивание: переместить точку опоры · Ctrl/⌘: без привязки", - "spriteFollowBasePivot": "Следовать базовой точке", + "spriteFollowBasePivot": "Базовая точка", "spriteStatePivot": "Точка состояния", "spriteRotation": "Поворот", "spriteScale": "Масштаб", diff --git a/src/renderer/locales/zh-Hant.json b/src/renderer/locales/zh-Hant.json index 55da78910..7ac569cfb 100644 --- a/src/renderer/locales/zh-Hant.json +++ b/src/renderer/locales/zh-Hant.json @@ -561,7 +561,7 @@ "spriteImageSelect": "選擇", "spritePivot": "基準點", "spritePivotHint": "拖曳:移動基準點 · Ctrl/⌘:取消吸附", - "spriteFollowBasePivot": "跟隨基礎基準點", + "spriteFollowBasePivot": "使用基礎基準點", "spriteStatePivot": "狀態基準點", "spriteRotation": "旋轉", "spriteScale": "縮放", diff --git a/src/renderer/locales/zh-cn.json b/src/renderer/locales/zh-cn.json index 7dee90176..2d914c0ed 100644 --- a/src/renderer/locales/zh-cn.json +++ b/src/renderer/locales/zh-cn.json @@ -561,7 +561,7 @@ "spriteImageSelect": "选择", "spritePivot": "基准点", "spritePivotHint": "拖动:移动基准点 · Ctrl/⌘:取消吸附", - "spriteFollowBasePivot": "跟随基础基准点", + "spriteFollowBasePivot": "使用基础基准点", "spriteStatePivot": "状态基准点", "spriteRotation": "旋转", "spriteScale": "缩放", From 56d2ac7c1608a77275f990852758a935c672afce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Fri, 4 Sep 2026 10:48:16 +0900 Subject: [PATCH 096/125] =?UTF-8?q?fix:=20=EC=9E=85=EB=A0=A5=20=EC=A7=81?= =?UTF-8?q?=ED=9B=84=20=EC=9E=90=EC=84=B8=20=ED=95=B8=EB=93=A4=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=EA=B0=80=20=EC=98=9B=20=EA=B0=92=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=8B=9C=EC=9E=91=ED=95=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...asHandles.poseSession.integration.test.tsx | 102 +++++++++ .../main/Grid/handles/SpriteCanvasHandles.tsx | 211 +++++++++--------- 2 files changed, 208 insertions(+), 105 deletions(-) diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx index 088e5871c..d3aea78af 100644 --- a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.poseSession.integration.test.tsx @@ -900,4 +900,106 @@ describe('자세 편집 세션 통합 (패널 + 캔버스 핸들)', () => { expect(canonicalSprite().pivot.x).toBeCloseTo(0.25, 9); expect(canonicalSprite().pivot.y).toBeCloseTo(0.8, 9); }); + + it('팝업에서 X를 입력한 직후 캔버스를 끌면 입력값 위에 이동량이 더해진다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + act(() => poseRows()[0].click()); + await settle(); + + // X=30을 입력하고 blur 없이 바로 캔버스 프레임을 잡는다 - 잡는 순간의 포커스 정산이 + // 30을 커밋하고, 드래그는 그 값 위에서 시작해야 한다 + // 패널의 요소 위치 X와 라벨이 같아 팝업 안에서 고른다 + const xInput = container.querySelector( + '[data-testid="pose-popup"] input[aria-label="propertiesPanel.position X"]', + )!; + act(() => { + xInput.focus(); + setInputValue(xInput, '30'); + }); + pointer('pointerdown', poseFrame()!, 60, 60); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + pointer('pointermove', window, 70, 60); + await settle(); + pointer('pointerup', window, 70, 60); + await settle(); + + // 직렬 큐 - 입력 저장이 끝나야 이동 저장이 나간다 + runtime.resolveNext(1); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(2); + const second = runtime.commit.mock.calls[1][0]; + const wirePose = second.changes?.spritePositions?.['4key']?.[0]?.poses?.[0]; + expect(wirePose?.transform.x).toBeCloseTo(40, 6); + expect(wirePose?.transform.y).toBeCloseTo(0, 6); + runtime.resolveNext(2); + await settle(); + expect(canonicalSprite().poses[0].transform.x).toBeCloseTo(40, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); + + it('상태 기준점을 입력한 직후 기준점을 끌어도 입력값 위에서 이어진다', async () => { + runtime.get.mockResolvedValue({ + revision: 0, + document: makeDocument(spriteFixture()), + }); + await harness.editorCoordinator.start(); + render(); + act(() => poseRows()[0].click()); + await settle(); + + // 기본 기준점 연결을 끊어 상태 기준점 입력을 연다 (after-paint 커밋) + const link = container.querySelector( + '[role="switch"][aria-label="propertiesPanel.spriteFollowBasePivot"]', + )!; + act(() => link.click()); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(1); + + // X=25%를 입력하고 blur 없이 바로 기준점 표식을 잡는다. 표식은 입력이 확정되기 + // 전이라 아직 옛 자리(x 50%)에 있고, 잡는 순간의 정산이 25%를 커밋한다 + const pivotX = container.querySelector( + 'input[aria-label="propertiesPanel.spriteStatePivot X"]', + )!; + act(() => { + pivotX.focus(); + setInputValue(pivotX, '25'); + }); + const start = pivotCenter(); + pointer('pointerdown', pivotHandle(), start.x, start.y); + await settle(); + // 표식은 입력값 자리로 옮겨져 있다 - 자세가 8.8° 돌아 있어 이미지 축의 -50px가 + // 화면에서는 회전해 보인다. 드래그는 이 자리에서 포인터 이동량만큼 이어진다 + const rad = (8.8 * Math.PI) / 180; + const settled = pivotCenter(); + expect(settled.x).toBeCloseTo(start.x - 50 * Math.cos(rad), 6); + expect(settled.y).toBeCloseTo(start.y - 50 * Math.sin(rad), 6); + pointer('pointermove', window, start.x + 20, start.y, { ctrlKey: true }); + await settle(); + pointer('pointerup', window, start.x + 20, start.y); + await settle(); + + runtime.resolveNext(1); + await settle(); + runtime.resolveNext(2); + await settle(); + expect(runtime.commit).toHaveBeenCalledTimes(3); + runtime.resolveNext(3); + await settle(); + + // 표식이 놓은 자리에 남고, 저장된 기준점은 25%에서 20px만큼 옮긴 값이다 + // (자세 회전 8.8°라 화면 +x는 이미지 축으로 되돌려 잰다) + const landed = pivotCenter(); + expect(landed.x).toBeCloseTo(settled.x + 20, 6); + expect(landed.y).toBeCloseTo(settled.y, 6); + const pivot = canonicalSprite().poses[0].pivot!; + expect(pivot.x).toBeCloseTo(0.25 + (20 * Math.cos(rad)) / 200, 6); + expect(pivot.y).toBeCloseTo(0.5 - (20 * Math.sin(rad)) / 150, 6); + expect(errors.filter((line) => /Failed|failed/.test(line))).toEqual([]); + }); }); diff --git a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx index b0b2b3662..db9e3b332 100644 --- a/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx +++ b/src/renderer/components/main/Grid/handles/SpriteCanvasHandles.tsx @@ -31,7 +31,6 @@ import { import { compensateTransformForPivotDelta, compensateTransformForPosePivotDelta, - isSameSpritePlacement, pivotHandleLocalPoint, pivotForHandleTarget, placeSpriteVisual, @@ -41,7 +40,6 @@ import { snapPosePivotToPreset, spriteIdleVisual, spritePivotChangePatch, - spritePoseVisual, type SpritePivotHandleFrame, type SpritePosePivotHandleFrame, } from '@utils/sprite/spritePlacement'; @@ -95,10 +93,8 @@ type PivotDragBase = } | { kind: 'pose'; - mode: string; - id: string; - canonical: ReactiveSpritePosition; poseId: string; + /** 세션(표시 기하)에서 뜬 프레임 - 저장 대기 중인 필드 편집도 그대로 담긴다 */ frame: SpritePosePivotHandleFrame; grabOffset: Point; }; @@ -131,6 +127,17 @@ interface PendingBasePivotLanding { pivot: SpriteAnchor; } +/** 최신 렌더의 표시 기하 - 드래그 시작이 포커스 정산 뒤에 읽는다 */ +interface HandleGeometry { + session: SpritePoseHandleSession | null; + sprite: ReactiveSpritePosition | null; + origin: { dx: number; dy: number }; + box: { width: number; height: number }; + imagePivot: SpriteAnchor; + displayTransform: SpriteTransform; + pivotLocal: Point; +} + const KNOB_HIT_SIZE = 22; const PIVOT_SNAP_PX = 8; const PIVOT_SNAP_RELEASE_PX = 12; @@ -231,6 +238,9 @@ const SpriteCanvasHandles = ({ const sessionRef = useRef(session); // eslint-disable-next-line react-hooks/refs sessionRef.current = session; + // 드래그 시작은 포커스 필드 정산(flushSync) 뒤에 온다. 정산이 값을 커밋하면 세션과 + // 표시 기하가 같은 프레임에 바뀌므로 시작 값은 렌더 클로저가 아니라 여기서 읽는다 + const latestRef = useRef(null); // 활성 드래그 취소 배선 - 세션 종료·대상 교체·언마운트에서 // window 리스너·포인터 캡처·전역 드래그 락·열린 preview 제스처를 함께 정리한다 @@ -268,6 +278,8 @@ const SpriteCanvasHandles = ({ }, [historyTick]); if (!sprite && !session) { + // eslint-disable-next-line react-hooks/refs + latestRef.current = null; if ( dragTransform !== null || dragPivot !== null || @@ -314,6 +326,16 @@ const SpriteCanvasHandles = ({ x: axis.x + displayTransform.x, y: axis.y + displayTransform.y, }; + // eslint-disable-next-line react-hooks/refs + latestRef.current = { + session, + sprite, + origin, + box, + imagePivot, + displayTransform, + pivotLocal, + }; // 선택 테두리와 리사이즈 핸들은 상자 밖 1px 프레임에 그려진다. 상자 핸들이 보이는 동안은 // 표식도 그 프레임에 앉혀 모서리·변에서 선·핸들 중심과 정확히 겹치게 한다. // 자세 편집 중에는 자세 프레임 선이 상자 그대로라 보정하지 않는다 @@ -341,14 +363,16 @@ const SpriteCanvasHandles = ({ } : null; - // 뷰(zoom·pan)는 ref에서 읽는다 - 드래그 중 최신 값이어야 팬·줌을 따라간다 + // 뷰(zoom·pan)와 원점은 ref에서 읽는다 - 드래그 중 최신 값이어야 팬·줌과 + // 정산된 위치 편집을 따라간다 const clientToLocal = (clientX: number, clientY: number): Point | null => { const hostRect = rootRef.current?.getBoundingClientRect(); if (!hostRect) return null; const view = viewRef.current; + const liveOrigin = latestRef.current?.origin ?? origin; return { - x: (clientX - hostRect.left - view.panX) / view.zoom - origin.dx, - y: (clientY - hostRect.top - view.panY) / view.zoom - origin.dy, + x: (clientX - hostRect.left - view.panX) / view.zoom - liveOrigin.dx, + y: (clientY - hostRect.top - view.panY) / view.zoom - liveOrigin.dy, }; }; @@ -408,42 +432,23 @@ const SpriteCanvasHandles = ({ const generationNow = () => useSpritePoseHandleStore.getState().generation; // 드래그 시작 때 잡은 세대가 아직 유효한지 - 자세 preview·커밋의 공통 전제 const ownsSession = () => ownerGenerationRef.current === generationNow(); - // 기준점 드래그의 전제 - 시작 시점 canonical이 아직 스토어의 그 객체인지. - // 리사이즈 착지·다른 창 편집·undo가 끼어들면 시작 시점 patch는 낡은 값이다 + // 기준점 드래그의 전제. 기본 기준점은 시작 시점 canonical이 아직 스토어의 그 객체인지 - + // 리사이즈 착지·다른 창 편집·undo가 끼어들면 시작 시점 patch는 낡은 값이다. + // 자세 기준점은 세션 프레임이 기준이라 세션 소유권으로 판정한다 (기하가 바뀌면 세대가 오른다) const pivotBaseCurrent = (drag: DragState): boolean => { const base = drag.pivotBase; if (!base) return false; + if (base.kind === 'pose') return ownsSession(); const current = findCanonicalSprite(base.mode, base.id); if (!current) return false; - if (base.kind === 'base') { - const placement = placeSpriteVisual(current, spriteIdleVisual(current)); - return ( - current.dx === base.canonical.dx && - current.dy === base.canonical.dy && - current.width === base.frame.box.width && - current.height === base.frame.box.height && - isSameSpriteAnchor(current.pivot, base.frame.pivot) && - isSameSpriteTransform(current.idleTransform, base.frame.transform) && - placement.rect.width === base.frame.rect.width && - placement.rect.height === base.frame.rect.height - ); - } - const pose = current.poses.find( - (candidate) => candidate.poseId === base.poseId, - ); - if (!pose) return false; - const placement = placeSpriteVisual( - current, - spritePoseVisual(current, pose), - ); - const axis = spritePivotPx(current); + const placement = placeSpriteVisual(current, spriteIdleVisual(current)); return ( current.dx === base.canonical.dx && current.dy === base.canonical.dy && - axis.x === base.frame.axis.x && - axis.y === base.frame.axis.y && - isSameSpriteAnchor(pose.pivot ?? current.pivot, base.frame.pivot) && - isSameSpriteTransform(pose.transform, base.frame.transform) && + current.width === base.frame.box.width && + current.height === base.frame.box.height && + isSameSpriteAnchor(current.pivot, base.frame.pivot) && + isSameSpriteTransform(current.idleTransform, base.frame.transform) && placement.rect.width === base.frame.rect.width && placement.rect.height === base.frame.rect.height ); @@ -807,13 +812,23 @@ const SpriteCanvasHandles = ({ ) => { if (event.button !== 0 || dragRef.current || !session) return; settleFocusedField(); + // 정산이 팝업 필드를 커밋했으면 세션은 이미 새 값이다 - 시작 자세도 그 값이어야 + // 입력한 값 위에 이동량이 더해진다 + const live = latestRef.current; + const liveSession = live?.session; + if (!live || !liveSession) return; const local = clientToLocal(event.clientX, event.clientY); if (!local) return; const startDistance = Math.hypot( - local.x - pivotLocal.x, - local.y - pivotLocal.y, + local.x - live.pivotLocal.x, + local.y - live.pivotLocal.y, ); - if (kind === 'scale' && startDistance * zoom < MIN_SCALE_ARM_PX) return; + if ( + kind === 'scale' && + startDistance * viewRef.current.zoom < MIN_SCALE_ARM_PX + ) { + return; + } event.preventDefault(); event.stopPropagation(); if (!tryAcquireDragSession()) return; @@ -824,11 +839,14 @@ const SpriteCanvasHandles = ({ pointerId: event.pointerId, sawPressedMove: false, startLocal: local, - baseTransform: session.transform, - axisLocal: pivotLocal, - startAngle: Math.atan2(local.y - pivotLocal.y, local.x - pivotLocal.x), + baseTransform: liveSession.transform, + axisLocal: live.pivotLocal, + startAngle: Math.atan2( + local.y - live.pivotLocal.y, + local.x - live.pivotLocal.x, + ), startDistance, - session, + session: liveSession, pivotBase: null, snappedPivot: null, }; @@ -846,78 +864,58 @@ const SpriteCanvasHandles = ({ const beginPivotDrag = (event: React.PointerEvent) => { if (event.button !== 0 || dragRef.current) return; settleFocusedField(); - const id = session ? session.positionId : sprite?.id; - if (!id || !isNativeElementId(id)) return; - const locator = resolveElementById('sprite', id); - if (!locator) return; - const canonical = findCanonicalSprite(locator.mode, id); - if (!canonical) return; - // 컨트롤러 소유권이 사라진 로컬 프리뷰는 새 조작보다 먼저 회수한다 - const reclaimedOrphanPreview = session - ? false - : editGestureController.discardOrphanedLocalPreviews(); - // 상자 크기는 역변환의 분모라 표시 크기가 canonical과 다르면 기준점이 틀어진다 - if ( - !reclaimedOrphanPreview && - (box.width !== canonical.width || box.height !== canonical.height) - ) { - refusePivotDrag('displayed box differs from canonical'); - return; - } + const live = latestRef.current; + if (!live) return; + const liveSession = live.session; let pivotBase: PivotDragBase; - if (session) { - const canonicalPose = canonical.poses.find( - (candidate) => candidate.poseId === session.poseId, - ); - if (!canonicalPose) { - refusePivotDrag('pose missing in canonical'); - return; - } - const canonicalImagePivot = canonicalPose.pivot ?? canonical.pivot; - if ( - !isSameSpriteAnchor(session.pivot, canonical.pivot) || - !isSameSpriteAnchor(imagePivot, canonicalImagePivot) - ) { - refusePivotDrag('session pivot differs from canonical'); - return; - } - if (!isSameSpriteTransform(session.transform, canonicalPose.transform)) { - refusePivotDrag('pose draft transform differs from canonical'); - return; - } - const canonicalPlacement = placeSpriteVisual( - canonical, - spritePoseVisual(canonical, canonicalPose), - ); - if (!isSameSpritePlacement(session.placement, canonicalPlacement)) { - refusePivotDrag('pose placement differs from canonical'); - return; - } + let reclaimedOrphanPreview = false; + if (liveSession) { + // 자세 편집 중에는 세션이 표시 기하의 원천이다. 방금 정산된 필드 커밋이 canonical에 + // 아직 닿지 않았어도 세션 프레임으로 역변환하고, 커밋은 저장 시점 최신 자세 위에 병합된다 pivotBase = { kind: 'pose', - mode: locator.mode, - id, - canonical, - poseId: canonicalPose.poseId, + poseId: liveSession.poseId, frame: { - axis: spritePivotPx(canonical), - rect: canonicalPlacement.rect, - pivot: canonicalImagePivot, - transform: displayTransform, + axis: spritePivotPx({ + width: liveSession.width, + height: liveSession.height, + pivot: liveSession.pivot, + }), + rect: liveSession.placement.rect, + pivot: liveSession.imagePivot, + transform: liveSession.transform, }, grabOffset: { x: 0, y: 0 }, }; } else { + const id = live.sprite?.id; + if (!id || !isNativeElementId(id)) return; + const locator = resolveElementById('sprite', id); + if (!locator) return; + const canonical = findCanonicalSprite(locator.mode, id); + if (!canonical) return; + // 컨트롤러 소유권이 사라진 로컬 프리뷰는 새 조작보다 먼저 회수한다 + reclaimedOrphanPreview = + editGestureController.discardOrphanedLocalPreviews(); + // 상자 크기는 역변환의 분모라 표시 크기가 canonical과 다르면 기준점이 틀어진다 if ( !reclaimedOrphanPreview && - !isSameSpriteAnchor(imagePivot, canonical.pivot) + (live.box.width !== canonical.width || + live.box.height !== canonical.height) + ) { + refusePivotDrag('displayed box differs from canonical'); + return; + } + if ( + !reclaimedOrphanPreview && + !isSameSpriteAnchor(live.imagePivot, canonical.pivot) ) { refusePivotDrag('displayed pivot differs from canonical'); return; } if ( !reclaimedOrphanPreview && - !isSameSpriteTransform(displayTransform, canonical.idleTransform) + !isSameSpriteTransform(live.displayTransform, canonical.idleTransform) ) { refusePivotDrag('displayed transform differs from canonical'); return; @@ -947,25 +945,28 @@ const SpriteCanvasHandles = ({ if (!tryAcquireDragSession()) return; grab(event, 'grabbing'); pivotBase.grabOffset = { - x: local.x - pivotLocal.x, - y: local.y - pivotLocal.y, + x: local.x - live.pivotLocal.x, + y: local.y - live.pivotLocal.y, }; - if (session) ownerGenerationRef.current = generationNow(); + if (liveSession) ownerGenerationRef.current = generationNow(); dragRef.current = { kind: 'pivot', pointerId: event.pointerId, sawPressedMove: false, startLocal: local, baseTransform: pivotBase.frame.transform, - axisLocal: pivotLocal, + axisLocal: live.pivotLocal, startAngle: 0, startDistance: 0, - session, + session: liveSession, pivotBase, snappedPivot: null, }; if (reclaimedOrphanPreview && pivotBase.kind === 'base') { - const recoveredPivot = pivotForHandleTarget(pivotBase.frame, pivotLocal); + const recoveredPivot = pivotForHandleTarget( + pivotBase.frame, + live.pivotLocal, + ); const recoveredTransform = compensateTransformForPivotDelta( pivotBase.frame, recoveredPivot, From 510c0cc32f560fcadae15639ee433fd8cf9668be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Fri, 4 Sep 2026 10:48:16 +0900 Subject: [PATCH 097/125] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=EC=9D=98=20=EC=8A=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EB=B9=84=EC=9C=A8=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=EA=B3=BC=20=EC=B5=9C=EC=86=8C=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/Grid/core/Grid.tsx | 3 +- .../Grid/handles/GroupResizeHandles.test.tsx | 128 +++++++++++++++ .../main/Grid/handles/GroupResizeHandles.tsx | 147 +++++++++++------- .../Grid/handles/groupResizeUtils.test.ts | 76 ++++++++- .../main/Grid/handles/groupResizeUtils.ts | 81 ++++++++++ 5 files changed, 379 insertions(+), 56 deletions(-) create mode 100644 src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx diff --git a/src/renderer/components/main/Grid/core/Grid.tsx b/src/renderer/components/main/Grid/core/Grid.tsx index 16667aed4..137bc10d5 100644 --- a/src/renderer/components/main/Grid/core/Grid.tsx +++ b/src/renderer/components/main/Grid/core/Grid.tsx @@ -58,6 +58,7 @@ import { useGradientEditStore } from '@stores/grid/useGradientEditStore'; import GroupResizeHandles from '../handles/GroupResizeHandles'; import { getElementBounds, + isAspectLockedElement, isElementResizable, } from '../handles/groupResizeUtils'; import { getGridViewportLayerStyles } from '@utils/core/gridViewportStyles'; @@ -1825,7 +1826,7 @@ const Grid = ({ elementId={elementId} getOtherElements={getOtherElements} // 스프라이트는 그림 레이어라 늘리지 않는다 - lockAspect={el.type === 'sprite'} + lockAspect={isAspectLockedElement(el)} occupiedHandle={occupiedHandle} /> ); diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx new file mode 100644 index 000000000..255da560e --- /dev/null +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; +import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; +import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; +import GroupResizeHandles from './GroupResizeHandles'; + +const SPRITE_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const SPRITE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + +// 폭 28 요소 - 그룹 폭이 30까지 줄면 size matching 임계값(4px) 안에 들어온다. +// 가장자리는 멀리 둬 정렬 스냅은 걸리지 않게 한다 +const sizeMatchTarget = (): SmartGuideElementBounds => ({ + id: 'other', + left: 1000, + top: 600, + right: 1028, + bottom: 1100, + centerX: 1014, + centerY: 850, + width: 28, + height: 500, +}); + +describe('GroupResizeHandles 최소 크기', () => { + let host: HTMLDivElement; + let root: Root; + let callbacks: Map; + + beforeEach(() => { + callbacks = new Map(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.set(1, callback); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => callbacks.delete(id)); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('크기 스냅이 그룹을 더 줄여도 요소는 10px 밑으로 내려가지 않는다', async () => { + // 100x100 스프라이트 둘이 (0,0)·(200,0)에 놓인 300x100 그룹. + // maxShrink는 그룹 30까지만 허용하지만 size matching이 28로 다시 당긴다 + const onGroupResize = vi.fn(); + const spritePositions = { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 100, + height: 100, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 200, + dy: 0, + width: 100, + height: 100, + }), + ], + }; + await act(async () => { + root.render( + [sizeMatchTarget()]} + />, + ); + }); + const handle = host.querySelector( + '[data-group-resize-handle="e"]', + )!; + act(() => + handle.dispatchEvent( + new MouseEvent('mousedown', { + clientX: 300, + clientY: 50, + bubbles: true, + cancelable: true, + }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: 0, clientY: 50 }), + ); + const callback = callbacks.get(1)!; + callbacks.clear(); + act(() => callback(performance.now())); + + expect(onGroupResize).toHaveBeenCalledTimes(1); + const result = onGroupResize.mock.calls[0][0]; + expect(result.groupBounds.width).toBe(30); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThanOrEqual(10); + } + // 화면 가이드도 실제 결과를 따른다 - 성립하지 않은 28px 크기 일치가 남으면 안 되고, + // 되돌린 뒤 남는 스냅이 없으니 가이드는 통째로 비운다 + const guides = useSmartGuidesStore.getState(); + expect(guides.sizeMatchGuides).toEqual([]); + expect(guides.draggedBounds).toBeNull(); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index 8dd914532..13e5c0ba3 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -29,9 +29,12 @@ import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { isElementResizable, + isAspectLockedElement, getElementBounds, calculateGroupBounds, elementBoundsChanged, + projectGroupElementBounds, + shrinkLimitSize, type Bounds, type SelectedElement, type ElementBounds, @@ -369,8 +372,8 @@ const GroupResizeHandles = ({ ): number => { if (!Number.isFinite(groupSize) || groupSize <= 0) return 0; let minScale = 0; - for (const { bounds } of boundsList) { - const size = axis === 'x' ? bounds.width : bounds.height; + for (const { element, bounds } of boundsList) { + const size = shrinkLimitSize(element, bounds, axis); if (!Number.isFinite(size) || size <= 0) continue; if (size >= MIN_SIZE) { minScale = Math.max(minScale, MIN_SIZE / size); @@ -433,7 +436,8 @@ const GroupResizeHandles = ({ handleDir: -1 | 0 | 1, maxShrink: number, ): number => { - if (!Number.isFinite(maxShrink) || maxShrink <= 0) return delta; + // maxShrink 0은 더 못 줄인다는 뜻이라 여기서도 잘라야 한다 + if (!Number.isFinite(maxShrink) || maxShrink < 0) return delta; const maxSnapped = snapSize > 0 ? Math.floor(maxShrink / snapSize) * snapSize @@ -502,6 +506,35 @@ const GroupResizeHandles = ({ newGroupWidth = Math.max(MIN_SIZE, newGroupWidth); newGroupHeight = Math.max(MIN_SIZE, newGroupHeight); + // 요소 하한 기준 그룹 최소 크기. 스냅이 maxShrink로 잘라 둔 델타를 다시 줄이면 + // 잡지 않은 가장자리를 고정한 채 되돌리고, 되돌린 축을 알려 그 축의 스냅을 무효화한다 + const minGroupWidth = Math.max( + MIN_SIZE, + startGroupBounds.width - maxShrinkX, + ); + const minGroupHeight = Math.max( + MIN_SIZE, + startGroupBounds.height - maxShrinkY, + ); + const enforceMinGroupSize = (): { width: boolean; height: boolean } => { + const clamped = { width: false, height: false }; + if (newGroupWidth < minGroupWidth) { + if (handle.dx === -1) { + newGroupX = newGroupX + newGroupWidth - minGroupWidth; + } + newGroupWidth = minGroupWidth; + clamped.width = true; + } + if (newGroupHeight < minGroupHeight) { + if (handle.dy === -1) { + newGroupY = newGroupY + newGroupHeight - minGroupHeight; + } + newGroupHeight = minGroupHeight; + clamped.height = true; + } + return clamped; + }; + // === 스마트 가이드 스냅 적용 (그룹 바운딩 박스 기준) === const smartGuidesStore = useSmartGuidesStore.getState(); const gridSettings = useSettingsStore.getState().gridSettings; @@ -607,17 +640,37 @@ const GroupResizeHandles = ({ } } + // 스냅이 요소 하한 아래로 내려갔으면 되돌린다. 되돌린 축의 정렬·간격·크기 일치는 + // 화면에서 성립하지 않으므로 가이드에서도 뺀다 - 가이드는 최종 결과를 따라야 한다 + const clamped = enforceMinGroupSize(); + const alignSnapX = snapResult.didSnapX && !clamped.width; + const alignSnapY = snapResult.didSnapY && !clamped.height; + const sizeSnapWidth = + sizeSnapResult?.didSnapWidth === true && !clamped.width; + const sizeSnapHeight = + sizeSnapResult?.didSnapHeight === true && !clamped.height; + const activeGuides = snapResult.guides.filter((guide) => + guide.type === 'vertical' ? alignSnapX : alignSnapY, + ); + const activeSpacingGuides = (snapResult.spacingGuides ?? []).filter( + (guide) => + guide.direction === 'horizontal' ? alignSnapX : alignSnapY, + ); + const activeSizeMatchGuides = ( + sizeSnapResult?.sizeMatchGuides ?? [] + ).filter((guide) => + guide.dimension === 'width' ? sizeSnapWidth : sizeSnapHeight, + ); + // 가이드라인 업데이트 const hasAlignSnap = (handle.dx !== 0 && - snapResult.didSnapX && + alignSnapX && !(snapResult.didSpacingSnapX && !spacingGuidesEnabled)) || (handle.dy !== 0 && - snapResult.didSnapY && + alignSnapY && !(snapResult.didSpacingSnapY && !spacingGuidesEnabled)); - const hasSizeSnap = - sizeSnapResult && - (sizeSnapResult.didSnapWidth || sizeSnapResult.didSnapHeight); + const hasSizeSnap = sizeSnapWidth || sizeSnapHeight; if (hasAlignSnap || hasSizeSnap) { const snappedBounds = calculateBounds( @@ -630,14 +683,10 @@ const GroupResizeHandles = ({ smartGuidesStore.setDraggedBounds(snappedBounds); if (hasAlignSnap) { - smartGuidesStore.setActiveGuides(snapResult.guides); - if ( - spacingGuidesEnabled && - snapResult.spacingGuides && - snapResult.spacingGuides.length > 0 - ) { + smartGuidesStore.setActiveGuides(activeGuides); + if (spacingGuidesEnabled && activeSpacingGuides.length > 0) { // 핸들 방향에 따라 간격 가이드 필터링 - const filteredSpacingGuides = snapResult.spacingGuides.filter( + const filteredSpacingGuides = activeSpacingGuides.filter( (guide: SpacingGuide) => { // 수평 방향 간격 가이드 (좌우 간격) if (guide.direction === 'horizontal') { @@ -696,9 +745,7 @@ const GroupResizeHandles = ({ } if (hasSizeSnap) { - smartGuidesStore.setSizeMatchGuides( - sizeSnapResult!.sizeMatchGuides, - ); + smartGuidesStore.setSizeMatchGuides(activeSizeMatchGuides); } else { smartGuidesStore.setSizeMatchGuides([]); } @@ -707,43 +754,28 @@ const GroupResizeHandles = ({ } } - // 최소 크기 재보장 (스마트 가이드 스냅 후) - newGroupWidth = Math.max(MIN_SIZE, newGroupWidth); - newGroupHeight = Math.max(MIN_SIZE, newGroupHeight); - - // 스케일 비율 계산 (스냅 적용된 그룹 bounds 기준) - const scaleX = - startGroupBounds.width > 0 ? newGroupWidth / startGroupBounds.width : 1; - const scaleY = - startGroupBounds.height > 0 - ? newGroupHeight / startGroupBounds.height - : 1; + // 스마트 스냅을 건너뛴 경로도 같은 하한을 지킨다 (스냅 경로에서는 이미 적용돼 무변화) + enforceMinGroupSize(); - // 각 리사이즈 가능한 요소의 새 위치/크기 계산 (그룹 스케일만 적용) + // 각 리사이즈 가능한 요소에 그룹 변환 투영 (스냅 적용된 그룹 bounds 기준). + // 스냅은 그룹 bounds에서만 처리하고, 비율 고정 요소는 단일 배율을 따른다 + const nextGroupBounds: Bounds = { + x: newGroupX, + y: newGroupY, + width: newGroupWidth, + height: newGroupHeight, + }; const newElementBounds: ElementBounds[] = startElementBounds.map( - ({ element, bounds }) => { - // 그룹 내에서의 상대 위치 - const relativeX = bounds.x - startGroupBounds.x; - const relativeY = bounds.y - startGroupBounds.y; - - // 새 위치 계산 (스케일 적용) - const newX = newGroupX + relativeX * scaleX; - const newY = newGroupY + relativeY * scaleY; - const newWidth = bounds.width * scaleX; - const newHeight = bounds.height * scaleY; - - // 내부 요소는 스케일만 적용해 비율을 유지한다. (스냅은 그룹 bounds에서만 처리) - - return { + ({ element, bounds }) => ({ + element, + bounds: projectGroupElementBounds( element, - bounds: { - x: newX, - y: newY, - width: newWidth, - height: newHeight, - }, - }; - }, + bounds, + startGroupBounds, + nextGroupBounds, + handle, + ), + }), ); // 새 그룹 bounds 계산 @@ -754,12 +786,19 @@ const GroupResizeHandles = ({ let finalGroupMaxX = newGroupX + newGroupWidth; let finalGroupMaxY = newGroupY + newGroupHeight; - // 리사이즈 불가능한 요소들 (원래 위치 유지) - for (const { bounds } of nonResizableElementBounds || []) { + const includeInGroup = (bounds: Bounds) => { finalGroupMinX = Math.min(finalGroupMinX, bounds.x); finalGroupMinY = Math.min(finalGroupMinY, bounds.y); finalGroupMaxX = Math.max(finalGroupMaxX, bounds.x + bounds.width); finalGroupMaxY = Math.max(finalGroupMaxY, bounds.y + bounds.height); + }; + // 리사이즈 불가능한 요소들 (원래 위치 유지) + for (const { bounds } of nonResizableElementBounds || []) { + includeInGroup(bounds); + } + // 비율 고정 요소는 한 축만 끌어도 반대 축이 함께 자라 그룹 변환 밖으로 나갈 수 있다 + for (const { element, bounds } of newElementBounds) { + if (isAspectLockedElement(element)) includeInGroup(bounds); } const result = { diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts index 93a206922..5b360bbd3 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { elementBoundsChanged, type ElementBounds } from './groupResizeUtils'; +import { + elementBoundsChanged, + projectGroupElementBounds, + shrinkLimitSize, + uniformGroupScale, + type Bounds, + type ElementBounds, +} from './groupResizeUtils'; const ID_A = '00000000-0000-0000-0000-000000000001'; +const ID_B = '00000000-0000-0000-0000-000000000002'; const boundsAt = (index: number, width = 40): ElementBounds => ({ element: { type: 'key', id: ID_A, index }, @@ -17,3 +25,69 @@ describe('elementBoundsChanged', () => { expect(elementBoundsChanged([boundsAt(0)], [boundsAt(1, 41)])).toBe(true); }); }); + +describe('projectGroupElementBounds', () => { + // 키 60x60 (0,0)과 스프라이트 200x125 (100,0)이 한 그룹: 300x125 + const key = { type: 'key', id: ID_A, index: 0 } as const; + const sprite = { type: 'sprite', id: ID_B, index: 0 } as const; + const keyBounds: Bounds = { x: 0, y: 0, width: 60, height: 60 }; + const spriteBounds: Bounds = { x: 100, y: 0, width: 200, height: 125 }; + const startGroup: Bounds = { x: 0, y: 0, width: 300, height: 125 }; + const rightHandle = { dx: 1, dy: 0 } as const; + + it('오른쪽 핸들로 가로만 늘리면 키는 늘어나고 스프라이트는 비율을 지킨다', () => { + const nextGroup: Bounds = { x: 0, y: 0, width: 385.65, height: 125 }; + const scaleX = 385.65 / 300; + + const projectedKey = projectGroupElementBounds( + key, + keyBounds, + startGroup, + nextGroup, + rightHandle, + ); + expect(projectedKey.width).toBeCloseTo(60 * scaleX, 9); + expect(projectedKey.height).toBe(60); + + const projected = projectGroupElementBounds( + sprite, + spriteBounds, + startGroup, + nextGroup, + rightHandle, + ); + // 257.1x125로 왜곡되지 않고 257.1x160.69로 함께 커진다 + expect(projected.width).toBeCloseTo(257.1, 9); + expect(projected.height).toBeCloseTo(125 * scaleX, 9); + expect(projected.width / projected.height).toBeCloseTo(200 / 125, 9); + // 중심은 그룹 배율대로 옮겨 다른 요소와 같은 자리를 지킨다 + expect(projected.x + projected.width / 2).toBeCloseTo(200 * scaleX, 9); + expect(projected.y + projected.height / 2).toBeCloseTo(62.5, 9); + }); + + it('두 축 배율이 같으면 비율 고정 요소도 일반 투영과 같은 상자를 얻는다', () => { + const nextGroup: Bounds = { x: 10, y: 20, width: 600, height: 250 }; + const projected = projectGroupElementBounds( + sprite, + spriteBounds, + startGroup, + nextGroup, + { dx: 1, dy: 1 }, + ); + expect(projected).toEqual({ x: 210, y: 20, width: 400, height: 250 }); + }); + + it('모서리 핸들은 변화가 큰 축의 배율을 따른다', () => { + expect(uniformGroupScale(1.5, 1.1, { dx: 1, dy: 1 })).toBe(1.5); + expect(uniformGroupScale(1.1, 0.5, { dx: -1, dy: 1 })).toBe(0.5); + expect(uniformGroupScale(1.5, 1.1, { dx: 0, dy: 1 })).toBe(1.1); + expect(uniformGroupScale(1.5, 1.1, { dx: -1, dy: 0 })).toBe(1.5); + }); + + it('비율 고정 요소의 축소 한계는 어느 축이든 짧은 변으로 잰다', () => { + expect(shrinkLimitSize(sprite, spriteBounds, 'x')).toBe(125); + expect(shrinkLimitSize(sprite, spriteBounds, 'y')).toBe(125); + expect(shrinkLimitSize(key, { ...keyBounds, width: 80 }, 'x')).toBe(80); + expect(shrinkLimitSize(key, { ...keyBounds, width: 80 }, 'y')).toBe(60); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts index a6cd0d416..2f24a9c04 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts @@ -43,6 +43,87 @@ export function elementBoundsChanged( }); } +/** 항상 비율을 지키는 요소 - 그림 레이어(스프라이트)는 늘리지 않는다 */ +export function isAspectLockedElement(element: SelectedElement): boolean { + return element.type === 'sprite'; +} + +export interface GroupResizeAxis { + dx: -1 | 0 | 1; + dy: -1 | 0 | 1; +} + +const groupScaleRatio = (start: number, next: number): number => + start > 0 && Number.isFinite(start) && Number.isFinite(next) + ? next / start + : 1; + +/** + * 비율 고정 요소가 따를 단일 배율. 드래그한 축의 배율을 쓰고, 두 축이 함께 움직이는 + * 모서리는 단일 리사이즈와 같은 규칙으로 변화가 큰 축을 따른다 + */ +export function uniformGroupScale( + scaleX: number, + scaleY: number, + handle: GroupResizeAxis, +): number { + if (handle.dx !== 0 && handle.dy === 0) return scaleX; + if (handle.dy !== 0 && handle.dx === 0) return scaleY; + return Math.abs(scaleX - 1) >= Math.abs(scaleY - 1) ? scaleX : scaleY; +} + +/** + * 그룹 변환을 요소 하나에 투영. 일반 요소는 그룹 배율을 축마다 그대로 받고, + * 비율 고정 요소는 단일 배율로 가로·세로를 함께 바꾸되 중심은 그룹 배율로 옮겨 + * 다른 요소와 같은 자리를 지킨다 + */ +export function projectGroupElementBounds( + element: SelectedElement, + bounds: Bounds, + startGroup: Bounds, + nextGroup: Bounds, + handle: GroupResizeAxis, +): Bounds { + const scaleX = groupScaleRatio(startGroup.width, nextGroup.width); + const scaleY = groupScaleRatio(startGroup.height, nextGroup.height); + const relativeX = bounds.x - startGroup.x; + const relativeY = bounds.y - startGroup.y; + if (!isAspectLockedElement(element)) { + return { + x: nextGroup.x + relativeX * scaleX, + y: nextGroup.y + relativeY * scaleY, + width: bounds.width * scaleX, + height: bounds.height * scaleY, + }; + } + const scale = uniformGroupScale(scaleX, scaleY, handle); + const width = bounds.width * scale; + const height = bounds.height * scale; + const centerX = nextGroup.x + (relativeX + bounds.width / 2) * scaleX; + const centerY = nextGroup.y + (relativeY + bounds.height / 2) * scaleY; + return { + x: centerX - width / 2, + y: centerY - height / 2, + width, + height, + }; +} + +/** + * 축소 한계 계산에 쓸 요소 치수. 비율 고정 요소는 어느 축을 끌어도 짧은 변이 + * 함께 줄어드므로 짧은 변으로 잰다 + */ +export function shrinkLimitSize( + element: SelectedElement, + bounds: Bounds, + axis: 'x' | 'y', +): number { + if (isAspectLockedElement(element)) { + return Math.min(bounds.width, bounds.height); + } + return axis === 'x' ? bounds.width : bounds.height; +} + /** * 요소가 리사이즈 가능한지 확인 */ From 4679698d85cc4701dff2f8d74255cedd6364da78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Fri, 4 Sep 2026 10:48:16 +0900 Subject: [PATCH 098/125] =?UTF-8?q?fix:=20=EB=B9=88=20=ED=83=AD=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EC=85=8B=EC=9D=B4=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=EB=A5=BC=20=EB=82=A8=EA=B8=B0=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/en/api-reference/presets/page.mdx | 5 +- .../content/ko/api-reference/presets/page.mdx | 4 +- src-tauri/src/commands/preset/load.rs | 70 +++++++++++++------ 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/docs/content/en/api-reference/presets/page.mdx b/docs/content/en/api-reference/presets/page.mdx index 61cfddbd8..16814e2e2 100644 --- a/docs/content/en/api-reference/presets/page.mdx +++ b/docs/content/en/api-reference/presets/page.mdx @@ -55,7 +55,10 @@ const result = await dmn.presets.saveTab(); ### `dmn.presets.loadTab(): Promise` Loads a tab preset file into the currently selected tab. Opens a file picker -dialog. +dialog. Element collections stored in the file (keys, stats, graphs, knobs, +sprites) replace the tab's contents wholesale, so elements missing from the +file's tab are removed. Older presets that lack a collection entirely leave +those elements untouched. ```javascript const result = await dmn.presets.loadTab(); diff --git a/docs/content/ko/api-reference/presets/page.mdx b/docs/content/ko/api-reference/presets/page.mdx index 41756da4f..a25a103a7 100644 --- a/docs/content/ko/api-reference/presets/page.mdx +++ b/docs/content/ko/api-reference/presets/page.mdx @@ -51,7 +51,9 @@ const result = await dmn.presets.saveTab(); ### loadTab() 탭 프리셋 파일을 현재 선택된 탭에 불러옵니다. 파일 선택 다이얼로그가 -열립니다. +열립니다. 파일에 담긴 요소 컬렉션(키·통계·그래프·노브·스프라이트)은 탭 +내용을 통째로 대체하므로 파일 쪽 탭에 없는 요소는 사라집니다. 컬렉션 자체가 +없는 옛 프리셋은 그 요소를 그대로 둡니다. ```javascript const result = await dmn.presets.loadTab(); diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index e0a72ba63..882338c6e 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -677,29 +677,14 @@ fn preset_load_tab_from_path( src_key_positions.insert(current_tab_id.clone(), v.clone()); } - let imported_stat_positions = stat_positions.unwrap_or_default(); - let mut src_stat_positions: StatPositions = HashMap::new(); - if let Some(v) = imported_stat_positions.get(&source_tab_id) { - src_stat_positions.insert(current_tab_id.clone(), v.clone()); - } - - let imported_graph_positions = graph_positions.unwrap_or_default(); - let mut src_graph_positions: GraphPositions = HashMap::new(); - if let Some(v) = imported_graph_positions.get(&source_tab_id) { - src_graph_positions.insert(current_tab_id.clone(), v.clone()); - } - - let imported_knob_positions = knob_positions.unwrap_or_default(); - let mut src_knob_positions: KnobPositions = HashMap::new(); - if let Some(v) = imported_knob_positions.get(&source_tab_id) { - src_knob_positions.insert(current_tab_id.clone(), v.clone()); - } - - let imported_sprite_positions = sprite_positions.unwrap_or_default(); - let mut src_sprite_positions: SpritePositions = HashMap::new(); - if let Some(v) = imported_sprite_positions.get(&source_tab_id) { - src_sprite_positions.insert(current_tab_id.clone(), v.clone()); - } + let mut src_stat_positions = + select_tab_preset_elements(stat_positions.as_ref(), &source_tab_id, ¤t_tab_id); + let mut src_graph_positions = + select_tab_preset_elements(graph_positions.as_ref(), &source_tab_id, ¤t_tab_id); + let mut src_knob_positions = + select_tab_preset_elements(knob_positions.as_ref(), &source_tab_id, ¤t_tab_id); + let mut src_sprite_positions = + select_tab_preset_elements(sprite_positions.as_ref(), &source_tab_id, ¤t_tab_id); migrate_imported_font_weights( &mut src_key_positions, @@ -1030,6 +1015,24 @@ fn normalize_imported_tab_css_overrides( overrides } +/// 탭 프리셋의 요소 컬렉션 하나를 대상 탭 키로 옮긴다. 컬렉션이 있는데 원본 탭 항목이 +/// 없으면 "요소 없음"이라 빈 목록을 넣어 기존 요소를 지운다 - 없으면 남는다. +/// 컬렉션 자체가 없는 옛 프리셋만 기존 요소를 그대로 둔다 +fn select_tab_preset_elements( + collection: Option<&HashMap>>, + source_tab_id: &str, + current_tab_id: &str, +) -> HashMap> { + let mut selected = HashMap::new(); + if let Some(collection) = collection { + selected.insert( + current_tab_id.to_string(), + collection.get(source_tab_id).cloned().unwrap_or_default(), + ); + } + selected +} + fn choose_tab_preset_source_tab( keys: &KeyMappings, selected_key_type: Option<&str>, @@ -3310,6 +3313,27 @@ mod tests { .is_none()); } + #[test] + fn tab_preset_collection_without_source_tab_entry_clears_target_tab() { + let sprites = SpritePositions::from([( + "other-tab".to_string(), + vec![ReactiveSpritePosition::default()], + )]); + + // 컬렉션은 있는데 원본 탭 항목이 없다 - 빈 탭이라 대상 탭도 비운다 + let selected = select_tab_preset_elements(Some(&sprites), "4key", "target"); + assert_eq!(selected.get("target").map(Vec::len), Some(0)); + + // 원본 탭 항목이 있으면 대상 탭 키로 옮긴다 + let selected = select_tab_preset_elements(Some(&sprites), "other-tab", "target"); + assert_eq!(selected.get("target").map(Vec::len), Some(1)); + assert!(!selected.contains_key("other-tab")); + + // 컬렉션 자체가 없는 옛 프리셋은 대상 탭을 건드리지 않는다 + let selected = select_tab_preset_elements::(None, "4key", "target"); + assert!(selected.is_empty()); + } + #[test] fn tab_preset_key_pair_merge_preserves_other_modes_and_latest_target_positions() { let mut store = AppStoreData { From b122c92994a2968fa00140c99372ce6479448a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Fri, 4 Sep 2026 10:48:16 +0900 Subject: [PATCH 099/125] =?UTF-8?q?fix:=20OBS=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=84=9C=EB=B9=99=20=ED=98=95=EC=8B=9D=EC=9D=84=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=B0=BD=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/media/image.rs | 14 ++----- src-tauri/src/services/obs_bridge.rs | 56 +++++++++++++++++++-------- src-tauri/src/state/image_asset.rs | 6 +++ 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/media/image.rs b/src-tauri/src/commands/media/image.rs index 08e711efa..877c7e46c 100644 --- a/src-tauri/src/commands/media/image.rs +++ b/src-tauri/src/commands/media/image.rs @@ -14,7 +14,7 @@ use tauri::{Manager, WebviewWindow}; use crate::{ commands::dialog::parented_file_dialog, errors::{CmdResult, CommandError}, - state::image_asset::import_image_file, + state::image_asset::{import_image_file, SUPPORTED_IMAGE_EXTENSIONS}, }; #[derive(Serialize)] @@ -44,15 +44,9 @@ pub async fn image_load( app: tauri::AppHandle, window: WebviewWindow, ) -> CmdResult { - let picked = parented_file_dialog( - &window, - "Images", - &[ - "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "ico", "avif", - ], - ) - .pick_file() - .await; + let picked = parented_file_dialog(&window, "Images", SUPPORTED_IMAGE_EXTENSIONS) + .pick_file() + .await; let Some(file) = picked else { return Ok(ImageLoadResponse { diff --git a/src-tauri/src/services/obs_bridge.rs b/src-tauri/src/services/obs_bridge.rs index b53cbfa4b..3bf8df6ba 100644 --- a/src-tauri/src/services/obs_bridge.rs +++ b/src-tauri/src/services/obs_bridge.rs @@ -30,6 +30,7 @@ use crate::models::obs::{ make_envelope, HelloAckPayload, InvokeRequestPayload, ObsBroadcast, ObsEnvelope, ObsStatus, OBS_PROTOCOL_VERSION, }; +use crate::state::image_asset::SUPPORTED_IMAGE_EXTENSIONS; const MAX_HTTP_HEADER_SIZE: usize = 16 * 1024; @@ -1214,22 +1215,7 @@ impl ObsBridgeService { .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); - if !matches!( - ext.as_str(), - "png" - | "jpg" - | "jpeg" - | "gif" - | "webp" - | "svg" - | "mp4" - | "webm" - | "ogg" - | "woff" - | "woff2" - | "ttf" - | "otf" - ) { + if !is_servable_media_extension(&ext) { let _ = stream .write_all( b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", @@ -1261,6 +1247,15 @@ impl ObsBridgeService { } /// 파일 확장자로 MIME 타입 추정 +// 이미지는 선택창 목록 그대로, 나머지는 영상·폰트만 +fn is_servable_media_extension(ext: &str) -> bool { + SUPPORTED_IMAGE_EXTENSIONS.contains(&ext) + || matches!( + ext, + "mp4" | "webm" | "ogg" | "woff" | "woff2" | "ttf" | "otf" + ) +} + fn guess_mime(path: &str) -> &'static str { match path .rsplit('.') @@ -1278,6 +1273,9 @@ fn guess_mime(path: &str) -> &'static str { "gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml", + "bmp" => "image/bmp", + "ico" => "image/x-icon", + "avif" => "image/avif", "mp4" => "video/mp4", "webm" => "video/webm", "ogg" => "video/ogg", @@ -1327,6 +1325,32 @@ mod tests { use super::*; use tokio_tungstenite::{connect_async, MaybeTlsStream}; + // 선택창이 받는 이미지는 전부 OBS에서도 서빙되고 image/* MIME으로 나가야 한다 + #[test] + fn media_whitelist_serves_every_picker_image_extension() { + for ext in SUPPORTED_IMAGE_EXTENSIONS { + assert!( + is_servable_media_extension(ext), + "{ext} 이미지가 OBS 서빙에서 빠짐" + ); + let mime = guess_mime(&format!("/app/images/sample.{ext}")); + assert!( + mime.starts_with("image/"), + "{ext} MIME이 image/*가 아님: {mime}" + ); + } + assert_eq!(guess_mime("/app/images/a.BMP"), "image/bmp"); + assert_eq!(guess_mime("/app/images/a.ico"), "image/x-icon"); + assert_eq!(guess_mime("/app/images/a.avif"), "image/avif"); + } + + #[test] + fn media_whitelist_still_rejects_non_media_files() { + for ext in ["json", "html", "js", "exe", "css", ""] { + assert!(!is_servable_media_extension(ext), "{ext} 서빙 허용됨"); + } + } + type TestWebSocket = WebSocketStream>; async fn receive_envelope(ws: &mut TestWebSocket, expected_type: &str) -> ObsEnvelope { diff --git a/src-tauri/src/state/image_asset.rs b/src-tauri/src/state/image_asset.rs index a18d0f219..42a4e570c 100644 --- a/src-tauri/src/state/image_asset.rs +++ b/src-tauri/src/state/image_asset.rs @@ -10,6 +10,12 @@ use uuid::Uuid; use super::atomic_file::{atomic_replace, atomic_replace_from_temp, sync_file_contents}; use super::local_asset_path::{file_url_to_path, FileUrlPath}; +// 이미지 선택창이 받는 확장자. OBS 브릿지 서빙 허용 목록도 이 목록을 따라야 +// 편집기·네이티브 오버레이에서 보이는 그림이 OBS에서만 빠지지 않는다 +pub(crate) const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[ + "png", "jpg", "jpeg", "webp", "gif", "bmp", "svg", "ico", "avif", +]; + pub(crate) fn probe_local_raster_size(image_ref: &str) -> Result> { let trimmed = image_ref.trim(); let path = match file_url_to_path(trimmed) { From 24c56c6b2ada986d6618c4494d0fc7cb685c0f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Fri, 4 Sep 2026 10:48:16 +0900 Subject: [PATCH 100/125] =?UTF-8?q?fix:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=B3=B5=EC=A0=9C=20=EA=B3=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20CSS=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/layers/SpriteDuplicateGhost.test.tsx | 69 +++++++++++++++++++ .../main/Grid/layers/SpriteDuplicateGhost.tsx | 61 +++++++++------- 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx index 584f9c408..b097e2e85 100644 --- a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.test.tsx @@ -121,4 +121,73 @@ describe('SpriteDuplicateGhost', () => { container.querySelector('[data-sprite-placeholder="true"]'), ).not.toBeNull(); }); + + it('사용자 클래스와 [data-sprite-element] 층을 아이템과 같게 둬 커스텀 CSS가 닿는다', () => { + act(() => { + root.render( + , + ); + }); + const ghost = container.querySelector( + '[data-sprite-ghost="true"]', + ); + expect(ghost?.classList.contains('left-hand')).toBe(true); + // 문서의 `.클래스 [data-sprite-element] > img` 선택자가 고스트에서도 성립한다 + const image = container.querySelector( + '.left-hand [data-sprite-element] > img', + ); + expect(image).not.toBeNull(); + const layer = ghost?.querySelector('[data-sprite-element]'); + expect(layer?.style.position).toBe('relative'); + expect(layer?.style.width).toBe('100%'); + expect(layer?.style.height).toBe('100%'); + }); + + it('기본 모드는 transform을 변수로만 실어 사용자 --sprite-transform이 이긴다', () => { + act(() => { + root.render( + , + ); + }); + const image = container.querySelector('img')!; + expect(image.style.transform).toBe(''); + expect(image.style.getPropertyValue('--dmn-sprite-transform-default')).toBe( + 'translate(4px, 0px) rotate(15deg) scale(1.2)', + ); + }); + + it('useInlineStyles=true는 아이템처럼 인라인 transform으로 승격한다', () => { + act(() => { + root.render( + , + ); + }); + const image = container.querySelector('img')!; + expect(image.style.transform).toBe( + 'translate(4px, 0px) rotate(15deg) scale(1.2)', + ); + }); + + it('클래스가 없으면 호스트 클래스에 빈 조각을 남기지 않는다', () => { + const ghost = renderGhost(); + expect(ghost?.className.includes('undefined')).toBe(false); + expect(ghost?.className.includes('null')).toBe(false); + }); }); diff --git a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx index 4cbf8901b..fb6c67c2c 100644 --- a/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteDuplicateGhost.tsx @@ -25,7 +25,9 @@ interface SpriteDuplicateGhostProps { } // 복제 배치 미리보기. 이미지 원점 규칙을 SpriteItem과 똑같이 맞춘다 - -// 호스트에 보더를 두면 안쪽 이미지가 밀려 놓는 순간 위치가 튄다 +// 호스트에 보더를 두면 안쪽 이미지가 밀려 놓는 순간 위치가 튄다. +// 사용자 클래스와 [data-sprite-element] 층도 아이템과 같아야 커스텀 CSS가 +// 고스트에 닿아 놓는 순간 외형이 바뀌지 않는다 const SpriteDuplicateGhost = ({ position, cursor, @@ -45,32 +47,41 @@ const SpriteDuplicateGhost = ({ width={width} height={height} cursor={cursor} + className={position.className ?? undefined} dataAttributes={{ 'data-sprite-ghost': 'true' }} > - {ghostImage ? ( - { - if (!isErrorForCurrentSrc(event.currentTarget, ghostImage)) return; - markFailed(ghostImage); - }} - /> - ) : ( - - )} +
+ {ghostImage ? ( + { + if (!isErrorForCurrentSrc(event.currentTarget, ghostImage)) { + return; + } + markFailed(ghostImage); + }} + /> + ) : ( + + )} +
Date: Sat, 5 Sep 2026 03:13:04 +0900 Subject: [PATCH 101/125] =?UTF-8?q?fix:=20=EC=8A=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=ED=81=AC=EA=B8=B0=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=9D=98=20=ED=8C=8C=EC=83=9D=20=EC=B6=95=20=EC=83=81=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.test.tsx | 36 +++++++++++++++++++ .../single/SingleSpritePanel.tsx | 18 ++++++---- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index c6b811b82..36308d02a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -1812,6 +1812,42 @@ describe('SingleSpritePanel 자세 편집', () => { ); }); + // 1024:1처럼 극단 비율이면 한 축의 정상 입력이 반대 축을 상한 밖으로 밀어낸다 + // 곱셈·나눗셈 분기가 따로라 두 방향 모두 고정한다 (9999/1024는 이진 정확) + it.each< + [ + axis: 'W' | 'H', + size: { width: number; height: number }, + expected: { width: number; height: number }, + ] + >([ + ['H', { width: 1024, height: 1 }, { width: 9999, height: 9.7646484375 }], + ['W', { width: 1, height: 1024 }, { width: 9.7646484375, height: 9999 }], + ])( + '파생 축이 상한을 넘으면 %s 입력을 역산해 두 축을 상한 안에 둔다', + (axis, size, expected) => { + const position = spritePosition(size); + seed(position); + render(position); + + // 100을 치면 반대 축은 102400 - 반대 축을 9999에 두고 입력 축을 되돌려 계산한다 + const input = container.querySelector( + `input[aria-label="propertiesPanel.size ${axis}"]`, + )!; + act(() => { + input.focus(); + setInputValue(input, '100'); + }); + act(() => input.blur()); + expect(mocks.commitBounds).toHaveBeenLastCalledWith( + 'sprite', + SPRITE_ID, + { dx: 0, dy: 0, ...expected }, + undefined, + ); + }, + ); + it('자세 이미지 선택은 파일창 결과를 자세에 커밋한다', async () => { const position = spritePosition({ poses: [ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index b33eeea04..a27b62f6f 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -500,20 +500,24 @@ export const SingleSpritePanel: React.FC = ({ ...patch, }); // 크기는 비율 고정 - 그림 레이어라 늘리지 않는다. 한쪽을 치면 다른 쪽이 따라간다 + // 파생 축도 상한 안에 든다 - 넘치면 파생 축을 상한에 놓고 입력 축을 역산한다 + // (극단 비율에서는 입력 축이 하한 아래로 내려간다, 백엔드는 양수면 받는다) const aspectBounds = ( field: 'width' | 'height', raw: number, ): EditorBoundsV1 => { - const value = clamp( - raw, - BOUNDS_EDIT_LIMITS.dimensionMin, - BOUNDS_EDIT_LIMITS.dimensionMax, - ); + const { dimensionMin, dimensionMax } = BOUNDS_EDIT_LIMITS; const ratio = canonicalPosition.height / canonicalPosition.width; + let value = clamp(raw, dimensionMin, dimensionMax); + let derived = field === 'width' ? value * ratio : value / ratio; + if (derived > dimensionMax) { + derived = dimensionMax; + value = field === 'width' ? derived / ratio : derived * ratio; + } return boundsWith( field === 'width' - ? { width: value, height: value * ratio } - : { height: value, width: value / ratio }, + ? { width: value, height: derived } + : { height: value, width: derived }, ); }; From 069951435bf34e2c10328ffd3e2dcbe4471e1da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 03:13:04 +0900 Subject: [PATCH 102/125] =?UTF-8?q?feat:=20=EB=B9=84=EC=9C=A8=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=B0=B0?= =?UTF-8?q?=EC=9C=A8=20=EB=B2=94=EC=9C=84=EC=99=80=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=83=81=ED=95=9C=20=ED=8C=90=EC=A0=95=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Grid/handles/aspectResize.test.ts | 327 ++++++++++++++++++ .../main/Grid/handles/aspectResize.ts | 217 ++++++++++++ .../main/Grid/handles/resizeLimits.test.ts | 90 +++++ .../main/Grid/handles/resizeLimits.ts | 65 ++++ src/types/editor.ts | 7 + 5 files changed, 706 insertions(+) create mode 100644 src/renderer/components/main/Grid/handles/aspectResize.test.ts create mode 100644 src/renderer/components/main/Grid/handles/aspectResize.ts create mode 100644 src/renderer/components/main/Grid/handles/resizeLimits.test.ts create mode 100644 src/renderer/components/main/Grid/handles/resizeLimits.ts diff --git a/src/renderer/components/main/Grid/handles/aspectResize.test.ts b/src/renderer/components/main/Grid/handles/aspectResize.test.ts new file mode 100644 index 000000000..91db575ff --- /dev/null +++ b/src/renderer/components/main/Grid/handles/aspectResize.test.ts @@ -0,0 +1,327 @@ +import { describe, expect, it } from 'vitest'; + +import { + aspectScaleFromPrimary, + aspectScaleRange, + isSameAspect, + scaleBoundsAnchored, + settleAspectScale, +} from './aspectResize'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; + +const MIN = 10; +const east = { dx: 1, dy: 0 } as const; +const south = { dx: 0, dy: 1 } as const; +const west = { dx: -1, dy: 0 } as const; + +describe('aspectScaleRange 하한', () => { + it('정상 4:3은 짧은 변이 10에서 멈춘다 (13.3x10 유지)', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 200, height: 150 }, + east, + MIN, + ); + expect(range.min).toBeCloseTo(10 / 150, 12); + }); + + it('한 축이 이미 얇으면 그 축은 기준에서 빠진다 (400x0.1은 폭 10까지)', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 400, height: 0.1 }, + east, + MIN, + ); + expect(range.min).toBeCloseTo(10 / 400, 12); + }); + + it('하한 배율은 반올림으로 10 아래를 만들지 않는다 (연속 축소 우회 방지)', () => { + // 10/77 은 아래로 반올림돼 77 * (10/77) = 9.999999999999998 + const start = { x: 0, y: 0, width: 100, height: 77 }; + const range = aspectScaleRange(start, east, MIN); + const shrunk = scaleBoundsAnchored(start, range.min, east); + expect(shrunk.height).toBeGreaterThanOrEqual(10); + // 그 결과를 다시 시작값으로 써도 두 축이 보호 축이라 10 아래로 못 내려간다 + const again = scaleBoundsAnchored( + shrunk, + aspectScaleRange(shrunk, east, MIN).min, + east, + ); + expect(again.height).toBeGreaterThanOrEqual(10); + expect(again.width).toBeGreaterThan(12.9); + }); + + it('두 축 모두 얇으면 현재보다 축소를 막는다', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 8, height: 4 }, + east, + MIN, + ); + expect(range.min).toBe(1); + expect(range.max).toBeGreaterThan(1); + }); +}); + +describe('aspectScaleRange 상한', () => { + it('두 치수가 32768을 넘지 않는 배율까지', () => { + const range = aspectScaleRange( + { x: 0, y: 0, width: 400, height: 0.1 }, + east, + MIN, + ); + expect(range.max).toBeCloseTo(32768 / 400, 12); + }); + + it('시작 가장자리 고정(dir 1)은 좌표를 제한하지 않는다', () => { + const range = aspectScaleRange( + { x: -32768, y: -32768, width: 100, height: 100 }, + { dx: 1, dy: 1 }, + MIN, + ); + expect(range.max).toBeCloseTo(32768 / 100, 12); + }); + + it('중앙 앵커는 왼쪽 가장자리가 -32768에 닿으면 멈춘다', () => { + // x' = cx - w·s/2 ≥ -32768. cx = -32368 이면 s ≤ 2, 이미 가장자리가 한계면 s ≤ 1 + expect( + aspectScaleRange({ x: -32568, y: 0, width: 400, height: 0.1 }, south, MIN) + .max, + ).toBeCloseTo(2, 12); + expect( + aspectScaleRange({ x: -32768, y: 0, width: 400, height: 0.1 }, south, MIN) + .max, + ).toBe(1); + }); + + it('반대 가장자리 고정(dir -1)은 x = right - w·s 로 제한한다', () => { + // right = -32668, s ≤ (right + 32768) / w = 100 / 100 = 1 → 확대 불가 + const range = aspectScaleRange( + { x: -32768, y: 0, width: 100, height: 100 }, + west, + MIN, + ); + expect(range.max).toBe(1); + }); +}); + +describe('aspectScaleRange 좌표 하한 (양쪽 부등식)', () => { + it('상단이 좌표 상한 근처면 중앙 앵커 축소가 하한을 올린다', () => { + // y=32760, h=100 을 가로 핸들로 줄이면 y' = 32810 - 50·s ≤ 32768 → s ≥ 0.84 + const start = { x: 0, y: 32760, width: 100, height: 100 }; + const range = aspectScaleRange(start, east, MIN); + expect(range.min).toBeCloseTo(0.84, 12); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, range.min, east)), + ).toBe(true); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, 0.5, east)), + ).toBe(false); + }); + + it('유효한 시작이면 s=1은 항상 구간 안이다', () => { + const starts = [ + { x: 32768, y: 32768, width: 32768, height: 32768 }, + { x: -32768, y: -32768, width: 0.001, height: 0.001 }, + { x: 32760, y: -32760, width: 100, height: 1 }, + // origin + size 를 먼저 더하면 정밀도가 깨져 빈 구간이 되던 극단값 + { x: 32768, y: -32768, width: 0.01141460293365526, height: 1e-12 }, + ]; + const handles = [ + east, + south, + west, + { dx: -1, dy: -1 }, + { dx: 1, dy: -1 }, + ] as const; + for (const start of starts) { + for (const handle of handles) { + const range = aspectScaleRange(start, handle, MIN); + expect(range.min).toBeLessThanOrEqual(1); + expect(range.max).toBeGreaterThanOrEqual(1); + } + } + }); +}); + +describe('scaleBoundsAnchored', () => { + const start = { x: 100, y: 50, width: 200, height: 100 }; + + it('s=1은 극단 좌표에서도 시작 bounds를 비트 단위로 돌려준다', () => { + const extreme = { + x: 32768, + y: -32768, + width: 0.01141460293365526, + height: 1e-12, + }; + const handle = { dx: -1, dy: -1 } as const; + expect(scaleBoundsAnchored(extreme, 1, handle)).toEqual(extreme); + const range = aspectScaleRange(extreme, handle, MIN); + expect(settleAspectScale(extreme, 1, handle, range)).toBe(1); + expect( + isBoundsWithinEditorLimits( + scaleBoundsAnchored( + extreme, + settleAspectScale(extreme, 3, handle, range), + handle, + ), + ), + ).toBe(true); + }); + + it('잡지 않은 가장자리를 고정한다', () => { + expect(scaleBoundsAnchored(start, 2, east)).toEqual({ + x: 100, + y: 0, + width: 400, + height: 200, + }); + expect(scaleBoundsAnchored(start, 0.5, west)).toEqual({ + x: 200, + y: 75, + width: 100, + height: 50, + }); + expect(scaleBoundsAnchored(start, 2, { dx: -1, dy: -1 })).toEqual({ + x: -100, + y: -50, + width: 400, + height: 200, + }); + }); + + it('두 변이 같은 배율로 움직인다', () => { + const next = scaleBoundsAnchored( + { x: 0, y: 0, width: 400, height: 0.1 }, + 11, + south, + ); + expect(isSameAspect({ x: 0, y: 0, width: 400, height: 0.1 }, next)).toBe( + true, + ); + }); +}); + +describe('settleAspectScale', () => { + it('범위로 자른 뒤 경계 오차가 있으면 s를 1 쪽으로 물린다', () => { + const start = { x: 0, y: 32760, width: 100, height: 100 }; + const range = aspectScaleRange(start, east, MIN); + const settled = settleAspectScale(start, 0.1, east, range); + expect(settled).toBeGreaterThanOrEqual(range.min); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, settled, east)), + ).toBe(true); + }); + + it('극소 치수는 배율이 표현 범위를 넘어도 NaN 없이 1로 닫힌다', () => { + // 1e-305 는 백엔드가 받는 유효 치수지만 5000/1e-305 은 Infinity + const tiny = { x: 0, y: 0, width: 1e-305, height: 1e-305 }; + const range = aspectScaleRange(tiny, east, MIN); + expect(Number.isFinite(range.max)).toBe(true); + const primary = aspectScaleFromPrimary( + tiny, + 'width', + 5000, + (v) => v, + range, + ); + // 배율로는 비율을 지킬 수 없으니 이 프레임은 움직이지 않는다 + expect(primary).toEqual({ + scale: 1, + exact: { axis: 'width', size: 1e-305 }, + }); + const settled = settleAspectScale(tiny, Infinity, east, range); + expect(settled).toBe(1); + expect(scaleBoundsAnchored(tiny, settled, east)).toEqual(tiny); + }); + + it('legacy 시작은 범위 밖 항목을 건드리지 않는 축소를 허용한다', () => { + // x=40000 은 범위 밖이지만 동쪽 핸들 축소는 x를 그대로 둔다 + const legacy = { x: 40000, y: 0, width: 200, height: 100 }; + const range = aspectScaleRange(legacy, east, MIN); + const settled = settleAspectScale(legacy, 0.5, east, range); + expect(settled).toBe(0.5); + expect(scaleBoundsAnchored(legacy, settled, east)).toEqual({ + x: 40000, + y: 25, + width: 100, + height: 50, + }); + }); + + it('상한 배율의 결과는 백엔드 검증을 통과한다', () => { + const cases = [ + { start: { x: -32768, y: 0, width: 400, height: 0.1 }, handle: south }, + { start: { x: 0, y: 0, width: 400, height: 0.1 }, handle: south }, + { + start: { x: 32700, y: 32700, width: 3, height: 7 }, + handle: { dx: -1, dy: -1 } as const, + }, + ]; + for (const { start, handle } of cases) { + const range = aspectScaleRange(start, handle, MIN); + const settled = settleAspectScale(start, Number.MAX_VALUE, handle, range); + expect( + isBoundsWithinEditorLimits(scaleBoundsAnchored(start, settled, handle)), + ).toBe(true); + } + }); +}); + +describe('aspectScaleFromPrimary', () => { + const snap5 = (size: number) => Math.round(size / 5) * 5; + const thin = { x: 0, y: 0, width: 400, height: 0.1 }; + + it('스냅이 치수 0으로 무너지면 그 프레임은 스냅을 버린다', () => { + const range = aspectScaleRange(thin, south, MIN); + // 아래쪽 +1px: 1.1은 그리드 5에서 0 → 무효 → 원래 후보 → 배율 11 + const unsnapped = aspectScaleFromPrimary(thin, 'height', 1.1, snap5, range); + expect(unsnapped.scale).toBeCloseTo(11, 12); + expect(unsnapped.exact).toEqual({ axis: 'height', size: 1.1 }); + // +3px: 3.1 → 5 유효 → 배율 50, 기준 축 치수는 스냅값 그대로 + const snapped = aspectScaleFromPrimary(thin, 'height', 3.1, snap5, range); + expect(snapped.scale).toBe(50); + expect(snapped.exact).toEqual({ axis: 'height', size: 5 }); + }); + + it('스냅이 상한을 넘으면 원래 후보를 상한으로 자른다', () => { + const start = { x: 0, y: 0, width: 100, height: 100 }; + const range = { min: 0.1, max: 1.022 }; + // 102.51 → 스냅 105는 범위 밖 → raw 1.0251 → clamp 1.022, 치수는 다시 곱한다 + const clamped = aspectScaleFromPrimary( + start, + 'width', + 102.51, + snap5, + range, + ); + expect(clamped.scale).toBe(1.022); + expect(clamped.exact.size).toBeCloseTo(102.2, 12); + // 102.49 → 스냅 100 유효 + expect( + aspectScaleFromPrimary(start, 'width', 102.49, snap5, range), + ).toEqual({ + scale: 1, + exact: { axis: 'width', size: 100 }, + }); + }); + + it('후보가 앵커를 지나 0 이하가 되면 하한으로 올린다', () => { + const start = { x: 0, y: 0, width: 200, height: 150 }; + const range = aspectScaleRange(start, east, MIN); + expect( + aspectScaleFromPrimary(start, 'width', -30, snap5, range).scale, + ).toBe(range.min); + }); + + it('왼쪽 핸들은 주입된 가장자리 스냅으로 폭을 정한다', () => { + const start = { x: 3, y: 0, width: 200, height: 150 }; + const right = start.x + start.width; + const edgeSnap = (size: number) => right - snap5(right - size); + const range = aspectScaleRange(start, west, MIN); + // 후보 폭 190 → 왼쪽 가장자리 13 → 스냅 15 → 폭 188 + expect( + aspectScaleFromPrimary(start, 'width', 190, edgeSnap, range), + ).toEqual({ + scale: 188 / 200, + exact: { axis: 'width', size: 188 }, + }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/aspectResize.ts b/src/renderer/components/main/Grid/handles/aspectResize.ts new file mode 100644 index 000000000..e7a502bc4 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/aspectResize.ts @@ -0,0 +1,217 @@ +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import { clamp } from '@utils/core/clamp'; + +import { + isBoundsTransitionWithinEditorLimits, + type ResizeBounds, +} from './resizeLimits'; + +// 비율 고정 리사이즈는 배율 하나로만 움직인다. 배율을 허용 범위로 자르고 잡지 않은 +// 가장자리를 고정해 두 치수를 함께 바꾸면 비율이 정확히 유지된다. 단일 핸들과 +// 스마트 스냅 재유도가 같은 함수를 쓴다 + +export interface ResizeAxisHandle { + dx: -1 | 0 | 1; + dy: -1 | 0 | 1; +} + +export type AspectPrimaryAxis = 'width' | 'height'; + +export interface ScaleRange { + min: number; + max: number; +} + +// 앵커별 저장 좌표는 origin + slope·(s - 1). 범위를 풀 때 (s - 1) 기준으로 써야 +// s=1이 정확히 시작 좌표가 된다 - origin + size 같은 합을 먼저 만들면 극단 좌표에서 +// 정밀도가 깨져 빈 구간이 나온다. dir 1은 시작 가장자리 고정(좌표 불변), +// 0은 중앙 고정, -1은 반대 가장자리 고정 +const anchorSlope = (size: number, dir: -1 | 0 | 1): number => + dir === 1 ? 0 : dir === 0 ? -size / 2 : -size; + +// -limit ≤ origin + slope·(s - 1) ≤ limit 을 만족하는 s 구간 +const coordinateScaleRange = ( + origin: number, + slope: number, + limit: number, +): ScaleRange => { + const lo = 1 + (-limit - origin) / slope; + const hi = 1 + (limit - origin) / slope; + return slope > 0 ? { min: lo, max: hi } : { min: hi, max: lo }; +}; + +/** + * 비율 고정 배율의 허용 범위 + * - 하한은 시작 시 minSize 이상인 축만 기준으로 잡아 이미 얇은 축을 억지로 키우지 + * 않고, 두 축 모두 얇으면 현재보다 축소를 막는다. 상한은 두 치수와 저장 좌표 + * 양쪽 부등식에서 나온다 (좌표 조건은 하한에도 기여한다 - 상단 가장자리가 좌표 + * 상한 근처면 중앙 앵커 축소가 좌표를 밖으로 민다) + * - 유효한 시작이면 s=1이 항상 구간 안이고, 범위 밖 legacy 시작은 s=1 쪽으로 닫는다 + */ +export const aspectScaleRange = ( + start: ResizeBounds, + handle: ResizeAxisHandle, + minSize: number, +): ScaleRange => { + const { maxDimension, maxAbsCoordinate } = EDITOR_BOUNDS_LIMITS; + const guarded = [start.width, start.height].filter((size) => size >= minSize); + let min = + guarded.length === 0 + ? 1 + : Math.max(...guarded.map((size) => minSize / size)); + // minSize / size 가 아래로 반올림되면 size * min 이 9.999…로 저장되고 다음 드래그에서 + // 얇은 축으로 분류돼 하한이 뚫린다 - 보호 축이 전부 하한 이상이 될 때까지 한 ulp씩 올린다 + while (guarded.some((size) => size * min < minSize)) { + min += min * Number.EPSILON; + } + let max = Math.min(maxDimension / start.width, maxDimension / start.height); + const axes: Array<[number, number, -1 | 0 | 1]> = [ + [start.x, start.width, handle.dx], + [start.y, start.height, handle.dy], + ]; + for (const [origin, size, dir] of axes) { + const slope = anchorSlope(size, dir); + if (slope === 0) continue; + const range = coordinateScaleRange(origin, slope, maxAbsCoordinate); + min = Math.max(min, range.min); + max = Math.min(max, range.max); + } + // 유효한 시작이면 s=1이 구간 안이다. 극단값의 부동소수 오차나 legacy 시작이 + // 구간을 비우지 않게 1을 명시적으로 포함하고, 극소 치수에서 상한 나눗셈이 + // 넘치면 유한값으로 막는다 (배율 자체가 표현 불가면 뒤의 settle이 닫는다) + const finiteMax = Number.isFinite(max) ? max : Number.MAX_VALUE; + return { min: Math.min(min, 1), max: Math.max(finiteMax, 1) }; +}; + +export interface ExactAxisSize { + axis: AspectPrimaryAxis; + size: number; +} + +// 잡지 않은 가장자리 고정 - 위치는 치수 차이의 몫으로 잡아 스냅된 정수 치수가 +// 정수 위치를 만든다 (배율 곱은 410이 409.99999999999994가 된다) +const anchorFraction = (dir: -1 | 0 | 1): number => + dir === 1 ? 0 : dir === 0 ? 0.5 : 1; + +/** + * 두 치수에 같은 배율, 잡지 않은 가장자리 고정. s=1이면 시작 bounds 그대로 + * - exact는 배율의 출처인 축의 스냅·클램프된 치수 - 그 축은 곱셈 대신 그 값을 써 + * 그리드 정수를 보존한다 + */ +export const scaleBoundsAnchored = ( + start: ResizeBounds, + scale: number, + handle: ResizeAxisHandle, + exact?: ExactAxisSize, +): ResizeBounds => { + if (scale === 1) return { ...start }; + const width = exact?.axis === 'width' ? exact.size : start.width * scale; + const height = exact?.axis === 'height' ? exact.size : start.height * scale; + return { + x: start.x + (start.width - width) * anchorFraction(handle.dx), + y: start.y + (start.height - height) * anchorFraction(handle.dy), + width, + height, + }; +}; + +/** + * 배율을 범위로 자르고, 최종 bounds가 백엔드 전이 검증을 통과할 때까지 s를 1 쪽으로 + * 조금씩 물린다 (경계값 역산의 부동소수 오차 대비). 그래도 통과하지 못하면 1로 + * 닫는다 - 시작 bounds 그대로는 백엔드가 항상 받으므로 무효 bounds는 절대 내보내지 + * 않는다 (극소 치수처럼 배율이 표현 범위를 넘는 경우) + */ +export const settleAspectScale = ( + start: ResizeBounds, + scale: number, + handle: ResizeAxisHandle, + range: ScaleRange, + exact?: ExactAxisSize, +): number => { + let settled = clamp(Number.isFinite(scale) ? scale : 1, range.min, range.max); + for (let attempt = 0; attempt < 8; attempt += 1) { + const exactSize = settled === scale ? exact : undefined; + if ( + isBoundsTransitionWithinEditorLimits( + start, + scaleBoundsAnchored(start, settled, handle, exactSize), + ) + ) { + return settled; + } + settled += (1 - settled) * 2 ** -20; + } + return 1; +}; + +/** + * 기준 축 후보 크기 → 배율. snapSize는 방향별 그리드 스냅(오른쪽·아래는 크기, + * 왼쪽·위는 움직이는 가장자리 좌표)을 호출자가 넣는다. 스냅이 범위 밖 배율을 + * 만들면 그 프레임은 스냅을 버리고 원래 후보를 쓴다 - 얇은 요소는 그리드 한 칸이 + * 치수 0으로 무너지고, 상한 근처는 스냅이 범위를 넘는다 + */ +export interface AspectPrimaryScale { + scale: number; + /** 배율의 출처가 된 기준 축 치수 - 클램프에 걸리지 않았으면 스냅·후보 값 그대로 */ + exact: ExactAxisSize; +} + +export const aspectScaleFromPrimary = ( + start: ResizeBounds, + primary: AspectPrimaryAxis, + candidateSize: number, + snapSize: (size: number) => number, + range: ScaleRange, +): AspectPrimaryScale => { + const startSize = primary === 'width' ? start.width : start.height; + const snappedSize = snapSize(candidateSize); + const snapped = snappedSize / startSize; + const useSnapped = snapped >= range.min && snapped <= range.max; + const chosenSize = useSnapped ? snappedSize : candidateSize; + const chosen = chosenSize / startSize; + // 극소 시작 치수는 배율이 표현 범위를 넘는다. 그 배율로는 비율을 지킬 수 없으니 + // 이 프레임은 움직이지 않는다 (1은 시작 bounds 그대로) + if (!Number.isFinite(chosen)) { + return { scale: 1, exact: { axis: primary, size: startSize } }; + } + const scale = clamp(chosen, range.min, range.max); + return { + scale, + exact: { + axis: primary, + size: scale === chosen ? chosenSize : startSize * scale, + }, + }; +}; + +/** 배율 출처 축의 정확한 치수 - settle이 배율을 옮겼으면 다시 곱한다 */ +export const exactSizeFor = ( + start: ResizeBounds, + scale: number, + fromScale: number, + exact: ExactAxisSize, +): ExactAxisSize => + scale === fromScale + ? exact + : { + axis: exact.axis, + size: (exact.axis === 'width' ? start.width : start.height) * scale, + }; + +// double 한 칸(ulp) - 배율 비교 허용치의 단위 +const ulpOf = (value: number): number => + 2 ** (Math.floor(Math.log2(Math.abs(value))) - 52); + +/** + * 두 변이 같은 배율로 움직였는지 - 테스트·검증용 불변식. 반대 축은 곱셈 한 번으로 + * 만들어지므로 정상 범위 double에서는 몇 ulp 안에 든다 + */ +export const isSameAspect = ( + start: ResizeBounds, + next: ResizeBounds, + toleranceUlps = 4, +): boolean => { + const sx = next.width / start.width; + const sy = next.height / start.height; + return Math.abs(sx - sy) <= toleranceUlps * ulpOf(Math.max(sx, sy)); +}; diff --git a/src/renderer/components/main/Grid/handles/resizeLimits.test.ts b/src/renderer/components/main/Grid/handles/resizeLimits.test.ts new file mode 100644 index 000000000..6afd7fb3a --- /dev/null +++ b/src/renderer/components/main/Grid/handles/resizeLimits.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; +import { + isBoundsTransitionWithinEditorLimits, + isBoundsWithinEditorLimits, +} from './resizeLimits'; + +describe('resizeLimits', () => { + it('상한 상수는 Rust editor.rs와 같다', () => { + const rustSource = readFileSync( + resolve(process.cwd(), 'src-tauri/src/state/editor.rs'), + 'utf8', + ); + const read = (name: string) => { + const match = rustSource.match( + new RegExp(`const ${name}: f64 = ([0-9_]+)\\.0;`), + ); + expect(match, `Rust ${name}`).not.toBeNull(); + return Number(match![1].replace(/_/g, '')); + }; + expect(read('MAX_DIMENSION')).toBe(EDITOR_BOUNDS_LIMITS.maxDimension); + expect(read('MAX_ABS_COORDINATE')).toBe( + EDITOR_BOUNDS_LIMITS.maxAbsCoordinate, + ); + }); + + it('저장 좌표 절댓값과 치수만 보고 오른쪽·아래 가장자리는 보지 않는다', () => { + expect( + isBoundsWithinEditorLimits({ + x: 32768, + y: -32768, + width: 32768, + height: 0.1, + }), + ).toBe(true); + // 오른쪽 가장자리 65536은 백엔드가 검사하지 않는다 + expect( + isBoundsWithinEditorLimits({ x: 32768, y: 0, width: 32768, height: 10 }), + ).toBe(true); + expect( + isBoundsWithinEditorLimits({ x: 32769, y: 0, width: 10, height: 10 }), + ).toBe(false); + expect( + isBoundsWithinEditorLimits({ x: 0, y: 0, width: 32769, height: 10 }), + ).toBe(false); + expect( + isBoundsWithinEditorLimits({ x: 0, y: 0, width: 0, height: 10 }), + ).toBe(false); + expect( + isBoundsWithinEditorLimits({ x: 0, y: NaN, width: 10, height: 10 }), + ).toBe(false); + }); + + it('전이 판정은 범위 밖 legacy 항목을 그대로 두거나 줄이는 후보만 받는다', () => { + const legacy = { x: 40000, y: 0, width: 40000, height: 100 }; + // 폭·x는 그대로, 높이만 바꿈 → 백엔드 non-increasing 규칙으로 통과 + expect( + isBoundsTransitionWithinEditorLimits(legacy, { ...legacy, height: 150 }), + ).toBe(true); + // 범위 밖 폭을 더 키우면 거부 + expect( + isBoundsTransitionWithinEditorLimits(legacy, { ...legacy, width: 40001 }), + ).toBe(false); + // 범위 밖 폭·좌표를 줄이면 통과 (절댓값 기준) + expect( + isBoundsTransitionWithinEditorLimits(legacy, { + ...legacy, + x: -39000, + width: 39000, + }), + ).toBe(true); + // 정상 시작에서 범위 밖으로 나가면 거부 + expect( + isBoundsTransitionWithinEditorLimits( + { x: 0, y: 0, width: 100, height: 100 }, + { x: 0, y: 0, width: 32769, height: 100 }, + ), + ).toBe(false); + // 비유한값은 비트 동일일 때만 + expect( + isBoundsTransitionWithinEditorLimits( + { x: 0, y: 0, width: 100, height: 100 }, + { x: NaN, y: 0, width: 100, height: 100 }, + ), + ).toBe(false); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/resizeLimits.ts b/src/renderer/components/main/Grid/handles/resizeLimits.ts new file mode 100644 index 000000000..da7da7c84 --- /dev/null +++ b/src/renderer/components/main/Grid/handles/resizeLimits.ts @@ -0,0 +1,65 @@ +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; + +export interface ResizeBounds { + x: number; + y: number; + width: number; + height: number; +} + +const { maxDimension, maxAbsCoordinate } = EDITOR_BOUNDS_LIMITS; + +const coordinateWithinLimit = (value: number): boolean => + Number.isFinite(value) && Math.abs(value) <= maxAbsCoordinate; + +const dimensionWithinLimit = (value: number): boolean => + Number.isFinite(value) && value > 0 && value <= maxDimension; + +// editor.rs numeric_metric_non_increasing 미러 - 범위 밖 현재값은 그대로 두거나 +// 절댓값(좌표)·크기(치수)가 줄어드는 후보만 받는다 +const nonIncreasing = ( + current: number, + candidate: number, + dimension: boolean, +): boolean => { + if (!Number.isFinite(current) || !Number.isFinite(candidate)) { + return Object.is(current, candidate); + } + if (dimension && (current <= 0 || candidate <= 0)) { + return Object.is(current, candidate); + } + return dimension + ? candidate <= current + : Math.abs(candidate) <= Math.abs(current); +}; + +// 백엔드 validate_bounds_metrics와 같은 판정. 저장 좌표(왼쪽·위)의 절댓값과 +// 치수(0 < v ≤ 상한)만 보고 오른쪽·아래 가장자리는 보지 않는다 +export const isBoundsWithinEditorLimits = (bounds: ResizeBounds): boolean => + coordinateWithinLimit(bounds.x) && + coordinateWithinLimit(bounds.y) && + dimensionWithinLimit(bounds.width) && + dimensionWithinLimit(bounds.height); + +/** + * 현재값을 아는 전이 판정 - 백엔드의 legacy 관용까지 미러한다. 이미 범위 밖인 + * 항목은 그대로 두거나 줄이는 후보를 통과시키므로, 폭이 범위 밖인 옛 요소도 + * 높이만 바꾸는 리사이즈는 막히지 않는다 + */ +export const isBoundsTransitionWithinEditorLimits = ( + current: ResizeBounds, + candidate: ResizeBounds, +): boolean => { + const coordinate = (before: number, after: number) => + coordinateWithinLimit(after) || + (!coordinateWithinLimit(before) && nonIncreasing(before, after, false)); + const dimension = (before: number, after: number) => + dimensionWithinLimit(after) || + (!dimensionWithinLimit(before) && nonIncreasing(before, after, true)); + return ( + coordinate(current.x, candidate.x) && + coordinate(current.y, candidate.y) && + dimension(current.width, candidate.width) && + dimension(current.height, candidate.height) + ); +}; diff --git a/src/types/editor.ts b/src/types/editor.ts index 162ab215b..05534b39e 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -151,6 +151,13 @@ export interface EditorBoundsV1 { height: number; } +// 백엔드 validate_bounds_metrics 상한 (editor.rs MAX_DIMENSION·MAX_ABS_COORDINATE) +// - 저장 좌표 dx·dy의 절댓값과 치수만 검사하고 오른쪽·아래 가장자리는 보지 않는다 +export const EDITOR_BOUNDS_LIMITS = { + maxDimension: 32_768, + maxAbsCoordinate: 32_768, +} as const; + export interface EditorSetBoundsOpV1 { kind: 'setBounds'; elementType: EditorElementTypeV1; From 43a219f255ed99e5e2fb238d2a9b7f0b31f66bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 03:13:04 +0900 Subject: [PATCH 103/125] =?UTF-8?q?fix:=20=EB=8B=A8=EC=9D=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=B9=84=EC=9C=A8=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EC=9E=AC=EC=9C=A0=EB=8F=84=EC=99=80=20=EC=9E=A1?= =?UTF-8?q?=EC=9D=80=20=EC=B6=95=20=ED=81=AC=EA=B8=B0=20=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/Grid/handles/ResizeHandles.test.tsx | 207 ++++++ .../main/Grid/handles/ResizeHandles.tsx | 167 +++-- .../hooks/Grid/useGridResize.test.tsx | 261 +++++++- src/renderer/hooks/Grid/useGridResize.ts | 599 +++++------------- src/renderer/utils/grid/smartGuides.test.ts | 44 ++ src/renderer/utils/grid/smartGuides.ts | 22 +- 6 files changed, 781 insertions(+), 519 deletions(-) diff --git a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx index cd0e4df0d..6a54e3005 100644 --- a/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/ResizeHandles.test.tsx @@ -13,6 +13,8 @@ import { setCustomCursorHover, suspendCustomCursorHover, } from '@utils/grid/cursorUtils'; +import { isSameAspect } from './aspectResize'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; import ResizeHandles from './ResizeHandles'; describe('ResizeHandles frame coalescing', () => { @@ -363,3 +365,208 @@ describe('ResizeHandles 자리 양보', () => { host.remove(); }); }); + +describe('ResizeHandles 비율 고정', () => { + let host: HTMLDivElement; + let root: Root; + let callbacks: Map; + + beforeEach(() => { + callbacks = new Map(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.set(1, callback); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => callbacks.delete(id)); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + interface DragOptions { + bounds: { x: number; y: number; width: number; height: number }; + lockAspect?: boolean; + handle: string; + dx: number; + dy: number; + shiftKey?: boolean; + } + + // 그리드 스냅 5(store 기본값), 줌 1. 마지막 프리뷰 bounds를 돌려준다 + const drag = async ({ + bounds, + lockAspect = false, + handle, + dx, + dy, + shiftKey = false, + }: DragOptions) => { + const onResize = vi.fn(); + const onResizeStart = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + const element = host.querySelector( + `[data-resize-handle="${handle}"]`, + )!; + act(() => + element.dispatchEvent( + new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + bubbles: true, + cancelable: true, + }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: dx, clientY: dy, shiftKey }), + ); + const callback = callbacks.get(1)!; + callbacks.clear(); + act(() => callback(performance.now())); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + const last = onResize.mock.calls.at(-1)?.[0] as + | (DragOptions['bounds'] & { aspect?: { primary: string } }) + | undefined; + return { last, onResize, onResizeStart }; + }; + + const thin = { x: 0, y: 0, width: 400, height: 0.1 }; + + it('가로 핸들은 파생 높이에 하한을 먹이지 않고 비율을 지킨다', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 'e', + dx: 10, + dy: 0, + }); + expect(last!.width).toBe(410); + expect(last!.height).toBeCloseTo(0.1025, 12); + expect(isSameAspect(thin, last!)).toBe(true); + expect(last!.aspect?.primary).toBe('width'); + }); + + it('세로 핸들 1px는 그리드 스냅이 0으로 무너져 스냅 없이 배율 11', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 's', + dx: 0, + dy: 1, + }); + expect(last!.width).toBeCloseTo(4400, 9); + expect(last!.height).toBeCloseTo(1.1, 12); + expect(last!.x).toBeCloseTo(-2000, 9); + expect(isSameAspect(thin, last!)).toBe(true); + }); + + it('세로 핸들 3px는 5로 스냅돼 배율 50', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 's', + dx: 0, + dy: 3, + }); + expect(last!.width).toBeCloseTo(20000, 9); + expect(last!.height).toBe(5); + expect(last!.x).toBeCloseTo(-9800, 9); + }); + + it('상한에 닿으면 폭 32768에서 멈추고 백엔드 검증을 통과한다', async () => { + const { last } = await drag({ + bounds: thin, + lockAspect: true, + handle: 's', + dx: 0, + dy: 100, + }); + expect(last!.width).toBeLessThanOrEqual(32768); + expect(last!.width).toBeGreaterThan(32767); + expect(isBoundsWithinEditorLimits(last!)).toBe(true); + expect(isSameAspect(thin, last!)).toBe(true); + }); + + it('왼쪽 핸들은 움직이는 가장자리를 스냅하고 오른쪽 가장자리를 고정한다', async () => { + const start = { x: 3, y: 0, width: 200, height: 150 }; + // 폭 190 후보 → 왼쪽 가장자리 13 → 스냅 15 → 폭 188, 배율 0.94 + const { last } = await drag({ + bounds: start, + lockAspect: true, + handle: 'w', + dx: 10, + dy: 0, + }); + expect(last).toMatchObject({ x: 15, width: 188 }); + expect(last!.height).toBeCloseTo(141, 12); + expect(last!.y).toBeCloseTo(4.5, 12); + expect(last!.x + last!.width).toBe(203); + }); + + it('Shift 비율 유지도 같은 경로를 탄다 (모서리는 변화 큰 축 기준)', async () => { + const start = { x: 0, y: 0, width: 100, height: 100 }; + const { last } = await drag({ + bounds: start, + handle: 'se', + dx: 20, + dy: 30, + shiftKey: true, + }); + expect(last).toMatchObject({ x: 0, y: 0, width: 130, height: 130 }); + }); + + it('이미 상한 밖인 legacy 요소는 확대 드래그에 움직이지 않는다', async () => { + const legacy = { x: 0, y: 0, width: 40000, height: 10 }; + const { onResize, onResizeStart } = await drag({ + bounds: legacy, + lockAspect: true, + handle: 'e', + dx: 10, + dy: 0, + }); + expect(onResize).not.toHaveBeenCalled(); + expect(onResizeStart).not.toHaveBeenCalled(); + }); + + it('연속 축소해도 하한 축이 10 아래로 반올림되지 않는다', async () => { + // 100x77 을 왼쪽 핸들로 크게 줄이면 높이가 10에서 멈춘다. 그 결과를 다시 줄여도 그대로 + const first = await drag({ + bounds: { x: 0, y: 0, width: 100, height: 77 }, + lockAspect: true, + handle: 'w', + dx: 95, + dy: 0, + }); + expect(first.last!.height).toBeGreaterThanOrEqual(10); + let bounds = { ...first.last! }; + for (let round = 0; round < 3; round += 1) { + const next = await drag({ + bounds, + lockAspect: true, + handle: 'w', + dx: 95, + dy: 0, + }); + if (next.last) bounds = { ...next.last }; + expect(bounds.height).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBeGreaterThan(12.9); + } + }); +}); diff --git a/src/renderer/components/main/Grid/handles/ResizeHandles.tsx b/src/renderer/components/main/Grid/handles/ResizeHandles.tsx index 22b6ff040..c4cd11200 100644 --- a/src/renderer/components/main/Grid/handles/ResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/ResizeHandles.tsx @@ -16,6 +16,15 @@ import { createRafLatestScheduler, type ContinuousInputStrategy, } from '@utils/animation/rafLatestScheduler'; +import { + aspectScaleFromPrimary, + aspectScaleRange, + exactSizeFor, + scaleBoundsAnchored, + settleAspectScale, + type AspectPrimaryAxis, + type ScaleRange, +} from './aspectResize'; import { SELECTION_BORDER_CENTER } from './selectionOutline'; /** @@ -49,6 +58,12 @@ interface ResizeResult { handle: HandleDef; /** 플랫폼 primary modifier 유지 중 - 스마트 가이드·크기 일치 스냅 일시 해제 */ suppressSmartSnap: boolean; + /** 비율 고정 중 - 스마트 스냅은 기준 축만 받고 반대 축은 같은 배율로 다시 구한다 */ + aspect?: { + start: Bounds; + primary: AspectPrimaryAxis; + range: ScaleRange; + }; } interface HandleProps { @@ -332,23 +347,6 @@ const ResizeHandles = ({ const snap = (value: number): number => snapToGrid(value, snapSize); - // 새 bounds 계산 (스냅 전) - let nextWidth = startBounds!.width; - let nextHeight = startBounds!.height; - - // 핸들 방향에 따라 크기 조정 (크기만 계산) - if (handle!.dx === -1) { - nextWidth = Math.max(MIN_SIZE, startBounds!.width - rawDeltaX); - } else if (handle!.dx === 1) { - nextWidth = Math.max(MIN_SIZE, startBounds!.width + rawDeltaX); - } - - if (handle!.dy === -1) { - nextHeight = Math.max(MIN_SIZE, startBounds!.height - rawDeltaY); - } else if (handle!.dy === 1) { - nextHeight = Math.max(MIN_SIZE, startBounds!.height + rawDeltaY); - } - const keepAspect = (lockAspect || !!moveEvent.shiftKey) && Number.isFinite(startAspectRatio) && @@ -358,12 +356,31 @@ const ResizeHandles = ({ startBounds!.width > 0 && startBounds!.height > 0; - let newWidth = nextWidth; - let newHeight = nextHeight; + let newX = startBounds!.x; + let newY = startBounds!.y; + let newWidth = startBounds!.width; + let newHeight = startBounds!.height; + let aspect: ResizeResult['aspect']; if (keepAspect) { + // 비율 고정은 배율 하나로만 움직인다. 하한 floor 없는 raw 후보로 기준 축을 + // 고르고(변 핸들은 그 축, 모서리는 상대 변화가 큰 축) 공용 규칙으로 배율을 + // 자른 뒤 잡지 않은 가장자리를 고정한다. 파생 축에 하한을 따로 먹이면 + // 얇은 요소의 비율이 깨지고, 상한이 없으면 백엔드가 커밋을 거부한다 + const rawWidth = + handle!.dx === -1 + ? startBounds!.width - rawDeltaX + : handle!.dx === 1 + ? startBounds!.width + rawDeltaX + : startBounds!.width; + const rawHeight = + handle!.dy === -1 + ? startBounds!.height - rawDeltaY + : handle!.dy === 1 + ? startBounds!.height + rawDeltaY + : startBounds!.height; const isCorner = handle!.dx !== 0 && handle!.dy !== 0; - const primary = + const primary: AspectPrimaryAxis = handle!.dx !== 0 && handle!.dy === 0 ? 'width' : handle!.dy !== 0 && handle!.dx === 0 @@ -371,24 +388,70 @@ const ResizeHandles = ({ : isCorner ? (() => { const relW = - Math.abs(newWidth - startBounds!.width) / startBounds!.width; + Math.abs(rawWidth - startBounds!.width) / startBounds!.width; const relH = - Math.abs(newHeight - startBounds!.height) / + Math.abs(rawHeight - startBounds!.height) / startBounds!.height; return relW >= relH ? 'width' : 'height'; })() : 'width'; + // 그리드 스냅 기준은 방향별 현행 유지 - 오른쪽·아래는 크기, 왼쪽·위는 + // 움직이는 가장자리 좌표 + const snapPrimarySize = (size: number): number => { + if (primary === 'width') { + if (handle!.dx !== -1) return snap(size); + const right = startBounds!.x + startBounds!.width; + return right - snap(right - size); + } + if (handle!.dy !== -1) return snap(size); + const bottom = startBounds!.y + startBounds!.height; + return bottom - snap(bottom - size); + }; + const range = aspectScaleRange(startBounds!, handle!, MIN_SIZE); + const primaryScale = aspectScaleFromPrimary( + startBounds!, + primary, + primary === 'width' ? rawWidth : rawHeight, + snapPrimarySize, + range, + ); + const scale = settleAspectScale( + startBounds!, + primaryScale.scale, + handle!, + range, + primaryScale.exact, + ); + const scaled = scaleBoundsAnchored( + startBounds!, + scale, + handle!, + exactSizeFor( + startBounds!, + scale, + primaryScale.scale, + primaryScale.exact, + ), + ); + newX = scaled.x; + newY = scaled.y; + newWidth = scaled.width; + newHeight = scaled.height; + aspect = { start: { ...startBounds! }, primary, range }; + } else { + // 핸들 방향에 따라 크기 조정 (크기만 계산) + if (handle!.dx === -1) { + newWidth = Math.max(MIN_SIZE, startBounds!.width - rawDeltaX); + } else if (handle!.dx === 1) { + newWidth = Math.max(MIN_SIZE, startBounds!.width + rawDeltaX); + } - if (primary === 'width') { - newWidth = Math.max(MIN_SIZE, snap(newWidth)); - const scale = newWidth / startBounds!.width; - newHeight = Math.max(MIN_SIZE, startBounds!.height * scale); - } else { - newHeight = Math.max(MIN_SIZE, snap(newHeight)); - const scale = newHeight / startBounds!.height; - newWidth = Math.max(MIN_SIZE, startBounds!.width * scale); + if (handle!.dy === -1) { + newHeight = Math.max(MIN_SIZE, startBounds!.height - rawDeltaY); + } else if (handle!.dy === 1) { + newHeight = Math.max(MIN_SIZE, startBounds!.height + rawDeltaY); } - } else { + // 드래그한 축만 스냅 - 반대 축까지 스냅하면 비배수 크기의 요소가 // 가로/세로 전용 리사이즈마다 1px씩 밀린다 (중앙 보정이 절반을 이동) if (handle!.dx !== 0) { @@ -397,41 +460,33 @@ const ResizeHandles = ({ if (handle!.dy !== 0) { newHeight = Math.max(MIN_SIZE, snap(newHeight)); } - } - // 위치 계산 (앵커 엣지 보존: 드래그하지 않은 쪽은 고정) - let newX = startBounds!.x; - let newY = startBounds!.y; + // 위치 계산 (앵커 엣지 보존: 드래그하지 않은 쪽은 고정) + if (handle!.dx === -1) { + // 좌측 핸들: 우측 엣지가 앵커 - 위치 스냅 후 width를 앵커에서 역산 + const rightAnchor = startBounds!.x + startBounds!.width; + newX = snap(rightAnchor - newWidth); + newWidth = rightAnchor - newX; + } + // dx === 1: newX = startBounds.x (좌측 앵커 유지) - if (handle!.dx === -1) { - // 좌측 핸들: 우측 엣지가 앵커 — 위치 스냅 후 width를 앵커에서 역산 - const rightAnchor = startBounds!.x + startBounds!.width; - newX = snap(rightAnchor - newWidth); - newWidth = rightAnchor - newX; - } else if (handle!.dx === 0) { - // 비례 유지 시 중앙 정렬 (스냅 없이 앵커 보존) - newX = startBounds!.x + (startBounds!.width - newWidth) / 2; - } - // dx === 1: newX = startBounds.x (좌측 앵커 유지) - - if (handle!.dy === -1) { - // 상단 핸들: 하단 엣지가 앵커 — 위치 스냅 후 height를 앵커에서 역산 - const bottomAnchor = startBounds!.y + startBounds!.height; - newY = snap(bottomAnchor - newHeight); - newHeight = bottomAnchor - newY; - } else if (handle!.dy === 0) { - // 비례 유지 시 중앙 정렬 (스냅 없이 앵커 보존) - newY = startBounds!.y + (startBounds!.height - newHeight) / 2; + if (handle!.dy === -1) { + // 상단 핸들: 하단 엣지가 앵커 - 위치 스냅 후 height를 앵커에서 역산 + const bottomAnchor = startBounds!.y + startBounds!.height; + newY = snap(bottomAnchor - newHeight); + newHeight = bottomAnchor - newY; + } + // dy === 1: newY = startBounds.y (상단 앵커 유지) } - // dy === 1: newY = startBounds.y (상단 앵커 유지) - const result = { + const result: ResizeResult = { x: newX, y: newY, width: newWidth, height: newHeight, handle: handle!, suppressSmartSnap: isMac() ? moveEvent.metaKey : moveEvent.ctrlKey, + aspect, }; const changed = result.x !== startBounds!.x || diff --git a/src/renderer/hooks/Grid/useGridResize.test.tsx b/src/renderer/hooks/Grid/useGridResize.test.tsx index 943424073..f8b6f8916 100644 --- a/src/renderer/hooks/Grid/useGridResize.test.tsx +++ b/src/renderer/hooks/Grid/useGridResize.test.tsx @@ -7,6 +7,7 @@ import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { useSpriteStore } from '@stores/data/useSpriteStore'; import type { ElementBounds } from '@utils/grid/smartGuides'; +import { aspectScaleRange } from '@components/main/Grid/handles/aspectResize'; import { useGridResize } from './useGridResize'; const mocks = vi.hoisted(() => ({ @@ -15,6 +16,9 @@ const mocks = vi.hoisted(() => ({ updateElement: vi.fn(), setResizing: vi.fn(), clearGuides: vi.fn(), + setActiveGuides: vi.fn(), + setSpacingGuides: vi.fn(), + setSizeMatchGuides: vi.fn(), commitPatch: vi.fn(() => Promise.resolve()), beginMixedGesture: vi.fn(), commitMixedGesture: vi.fn(() => Promise.resolve()), @@ -80,9 +84,9 @@ vi.mock('@stores/grid/useSmartGuidesStore', () => ({ getState: () => ({ clearGuides: mocks.clearGuides, setDraggedBounds: vi.fn(), - setActiveGuides: vi.fn(), - setSpacingGuides: vi.fn(), - setSizeMatchGuides: vi.fn(), + setActiveGuides: mocks.setActiveGuides, + setSpacingGuides: mocks.setSpacingGuides, + setSizeMatchGuides: mocks.setSizeMatchGuides, }), }, })); @@ -256,6 +260,9 @@ describe('useGridResize plugin gesture lifecycle', () => { mocks.updateElement.mockReset(); mocks.setResizing.mockReset(); mocks.clearGuides.mockReset(); + mocks.setActiveGuides.mockReset(); + mocks.setSpacingGuides.mockReset(); + mocks.setSizeMatchGuides.mockReset(); mocks.commitPatch.mockClear(); mocks.commitGroupBounds.mockClear(); mocks.commitSingleBounds.mockClear(); @@ -753,4 +760,252 @@ describe('useGridResize plugin gesture lifecycle', () => { expect(mocks.end).toHaveBeenCalledWith('plugin-a', 'token-1'); expect(mocks.setResizing).toHaveBeenLastCalledWith(false); }); + it('비율 고정 리사이즈는 기준 축의 크기 일치만 받고 반대 축을 같은 배율로 놓는다', async () => { + mocks.gridSettings = { + alignmentGuides: true, + spacingGuides: false, + sizeMatchGuides: true, + }; + useSpriteStore.setState({ + positions: { + '4key': [{ ...spriteAt(STABLE_SPRITE), width: 110, height: 55 }], + }, + }); + // 폭 112는 임계값 4px 안, 높이 60은 밖. 가장자리는 멀리 둬 정렬 스냅은 없다 + const other: ElementBounds = { + id: 'other', + left: 1000, + top: 1000, + right: 1112, + bottom: 1060, + centerX: 1056, + centerY: 1030, + width: 112, + height: 60, + }; + await renderHarness( + [{ type: 'sprite', id: STABLE_SPRITE, index: 0 }], + () => [other], + ); + const start = { x: 0, y: 0, width: 110, height: 55 }; + const handle = { id: 'e', dx: 1, dy: 0 }; + await act(async () => { + api.handleResizeStart(); + }); + await act(async () => { + api.handleResize({ + ...start, + handle, + aspect: { + start, + primary: 'width', + range: aspectScaleRange(start, { dx: 1, dy: 0 }, 10), + }, + }); + }); + expect(api.previewBounds?.width).toBe(112); + expect(api.previewBounds?.height).toBeCloseTo(56, 9); + expect(api.previewBounds?.y).toBeCloseTo(-0.5, 9); + expect(api.previewBounds?.x).toBe(0); + }); + + it('기준 축 스냅이 배율 범위를 넘으면 상한으로 자른다', async () => { + mocks.gridSettings = { + alignmentGuides: true, + spacingGuides: false, + sizeMatchGuides: true, + }; + useSpriteStore.setState({ + positions: { + '4key': [{ ...spriteAt(STABLE_SPRITE), width: 100, height: 100 }], + }, + }); + const other: ElementBounds = { + id: 'other', + left: 1000, + top: 1000, + right: 1104, + bottom: 1500, + centerX: 1052, + centerY: 1250, + width: 104, + height: 500, + }; + await renderHarness( + [{ type: 'sprite', id: STABLE_SPRITE, index: 0 }], + () => [other], + ); + const start = { x: 0, y: 0, width: 100, height: 100 }; + await act(async () => { + api.handleResizeStart(); + }); + await act(async () => { + api.handleResize({ + ...start, + handle: { id: 'e', dx: 1, dy: 0 }, + aspect: { start, primary: 'width', range: { min: 0.5, max: 1.02 } }, + }); + }); + // 크기 일치 104 → 배율 1.04 는 상한 1.02 밖 → 102x102, 세로는 중앙 고정 + expect(api.previewBounds).toEqual({ x: 0, y: -1, width: 102, height: 102 }); + }); + + it('잡지 않은 축의 크기 일치는 무시한다 (가로 핸들은 높이를 바꾸지 않는다)', async () => { + mocks.gridSettings = { + alignmentGuides: true, + spacingGuides: false, + sizeMatchGuides: true, + }; + mocks.keyPositions = [ + { id: STABLE_A, dx: 0, dy: 0, width: 120, height: 60 }, + ]; + // 높이 62는 임계값 안, 폭 500은 밖 + const other: ElementBounds = { + id: 'other', + left: 1000, + top: 1000, + right: 1500, + bottom: 1062, + centerX: 1250, + centerY: 1031, + width: 500, + height: 62, + }; + await renderHarness([stableKeySelection(STABLE_A)], () => [other]); + await act(async () => { + api.handleResizeStart(); + }); + await act(async () => { + api.handleResize({ + x: 0, + y: 0, + width: 118, + height: 60, + handle: { id: 'e', dx: 1, dy: 0 }, + }); + }); + expect(api.previewBounds).toEqual({ x: 0, y: 0, width: 118, height: 60 }); + }); + it('플러그인 단일 리사이즈도 비율 고정 재유도를 같은 경로로 받는다', async () => { + mocks.gridSettings = { + alignmentGuides: true, + spacingGuides: false, + sizeMatchGuides: true, + }; + mocks.elements = [{ fullId: 'plugin-a', pluginId: 'plugin' }]; + // 폭 104는 임계값 안, 높이 500은 밖 + const other: ElementBounds = { + id: 'other', + left: 1000, + top: 1000, + right: 1104, + bottom: 1500, + centerX: 1052, + centerY: 1250, + width: 104, + height: 500, + }; + await renderHarness([pluginSelection('plugin-a')], () => [other]); + const start = { x: 0, y: 0, width: 100, height: 100 }; + await act(async () => { + api.handleResizeStart(); + }); + await act(async () => { + api.handleResize({ + ...start, + handle: { id: 'se', dx: 1, dy: 1 }, + aspect: { start, primary: 'width', range: { min: 0.1, max: 10 } }, + }); + }); + // Shift 모서리: 폭 크기 일치 104 → 높이도 같은 배율 104 + expect(api.previewBounds).toEqual({ x: 0, y: 0, width: 104, height: 104 }); + }); + + it('비율 고정은 무시한 반대 축의 정렬 가이드를 그리지 않는다', async () => { + mocks.gridSettings = { + alignmentGuides: true, + spacingGuides: false, + sizeMatchGuides: false, + }; + useSpriteStore.setState({ + positions: { + '4key': [{ ...spriteAt(STABLE_SPRITE), width: 100, height: 100 }], + }, + }); + // 오른쪽 가장자리 105는 오른쪽 108에(3px), 위쪽 0은 위쪽 0에 정렬 후보 - 두 축 모두 임계값 안 + const other: ElementBounds = { + id: 'other', + left: 8, + top: 0, + right: 108, + bottom: 100, + centerX: 58, + centerY: 50, + width: 100, + height: 100, + }; + await renderHarness( + [{ type: 'sprite', id: STABLE_SPRITE, index: 0 }], + () => [other], + ); + const start = { x: 0, y: 0, width: 100, height: 100 }; + await act(async () => { + api.handleResizeStart(); + }); + await act(async () => { + api.handleResize({ + x: 0, + y: 0, + width: 105, + height: 105, + handle: { id: 'se', dx: 1, dy: 1 }, + aspect: { start, primary: 'width', range: { min: 0.1, max: 10 } }, + }); + }); + // 폭만 108로 스냅되고 높이는 같은 배율로 108 + expect(api.previewBounds).toEqual({ x: 0, y: 0, width: 108, height: 108 }); + const guides = mocks.setActiveGuides.mock.calls.at(-1)?.[0] as Array<{ + type: string; + }>; + expect(guides.length).toBeGreaterThan(0); + expect(guides.every((guide) => guide.type === 'vertical')).toBe(true); + }); + + it('잡지 않은 축은 정렬 후보가 있어도 위치를 옮기지 않는다', async () => { + mocks.gridSettings = { + alignmentGuides: true, + spacingGuides: false, + sizeMatchGuides: false, + }; + mocks.keyPositions = [ + { id: STABLE_A, dx: 3, dy: 0, width: 100, height: 100 }, + ]; + // 왼쪽 가장자리 3은 다른 요소의 왼쪽 0에 정렬 후보(3px) - 아래 핸들이라 X는 잡지 않은 축 + const other: ElementBounds = { + id: 'other', + left: 0, + top: 500, + right: 100, + bottom: 600, + centerX: 50, + centerY: 550, + width: 100, + height: 100, + }; + await renderHarness([stableKeySelection(STABLE_A)], () => [other]); + await act(async () => { + api.handleResizeStart(); + }); + await act(async () => { + api.handleResize({ + x: 3, + y: 0, + width: 100, + height: 120, + handle: { id: 's', dx: 0, dy: 1 }, + }); + }); + expect(api.previewBounds).toEqual({ x: 3, y: 0, width: 100, height: 120 }); + expect(mocks.setActiveGuides).not.toHaveBeenCalled(); + }); }); diff --git a/src/renderer/hooks/Grid/useGridResize.ts b/src/renderer/hooks/Grid/useGridResize.ts index c330c2c7b..4bd88659f 100644 --- a/src/renderer/hooks/Grid/useGridResize.ts +++ b/src/renderer/hooks/Grid/useGridResize.ts @@ -26,6 +26,14 @@ import { import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; import type { ElementBounds } from '@utils/grid/smartGuides'; +import { + exactSizeFor, + scaleBoundsAnchored, + settleAspectScale, + type AspectPrimaryAxis, + type ScaleRange, +} from '@components/main/Grid/handles/aspectResize'; +import type { ResizeBounds as AspectStartBounds } from '@components/main/Grid/handles/resizeLimits'; import { beginPluginInstancesEditSession, endPluginInstancesEditSession, @@ -174,7 +182,7 @@ export function useGridResize({ useGridSelectionStore.getState().setResizing(true); }; - // 공용 리사이즈 프리뷰 처리 (스마트 가이드 포함) + // 공용 리사이즈 프리뷰 처리 (스마트 가이드 포함) - native·플러그인 단일 리사이즈가 함께 쓴다 const handleElementResizePreview = ( elementId: string, newBounds: { @@ -184,6 +192,12 @@ export function useGridResize({ height: number; handle?: ResizeHandle; suppressSmartSnap?: boolean; + /** 비율 고정 - 기준 축만 스냅하고 반대 축은 같은 배율로 다시 구한다 */ + aspect?: { + start: AspectStartBounds; + primary: AspectPrimaryAxis; + range: ScaleRange; + }; }, ) => { const smartGuidesStore = useSmartGuidesStore.getState(); @@ -222,14 +236,26 @@ export function useGridResize({ }, ); const handle = newBounds.handle; + const aspect = newBounds.aspect; if (handle) { + // 비율 고정 중에는 기준 축의 스냅만 받는다 - 반대 축은 같은 배율로 다시 구한다 + const snapWidthAllowed = !aspect || aspect.primary === 'width'; + const snapHeightAllowed = !aspect || aspect.primary === 'height'; + + // 반영한 축만 가이드를 그린다 - 비율 고정이 무시한 반대 축과 clamp에 잘린 스냅 제외 + // (잡지 않은 축은 정렬 후보가 있어도 위치를 안 옮긴다) + let appliedSnapX = false; + let appliedSnapY = false; + // X축 스냅 (간격 스냅인 경우 spacingGuidesEnabled 확인) - if ( + const alignSnapX = handle.dx !== 0 && + snapWidthAllowed && snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled) - ) { + !(snapResult.didSpacingSnapX && !spacingGuidesEnabled); + if (alignSnapX) { + appliedSnapX = true; if (handle.dx === -1) { // 왼쪽 핸들: 왼쪽 가장자리 스냅 const widthDiff = finalX - snapResult.snappedX; @@ -239,18 +265,17 @@ export function useGridResize({ // 오른쪽 핸들: 오른쪽 가장자리 스냅 const snappedRight = snapResult.snappedX + draggedBounds.width; finalWidth = snappedRight - finalX; - } else if (handle.dx === 0) { - // 수직 핸들 (상/하): 중앙 정렬 스냅 - finalX = snapResult.snappedX; } } // Y축 스냅 (간격 스냅인 경우 spacingGuidesEnabled 확인) - if ( + const alignSnapY = handle.dy !== 0 && + snapHeightAllowed && snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled) - ) { + !(snapResult.didSpacingSnapY && !spacingGuidesEnabled); + if (alignSnapY) { + appliedSnapY = true; if (handle.dy === -1) { // 위쪽 핸들: 위쪽 가장자리 스냅 const heightDiff = finalY - snapResult.snappedY; @@ -260,165 +285,115 @@ export function useGridResize({ // 아래쪽 핸들: 아래쪽 가장자리 스냅 const snappedBottom = snapResult.snappedY + draggedBounds.height; finalHeight = snappedBottom - finalY; - } else if (handle.dy === 0) { - // 수평 핸들 (좌/우): 중앙 정렬 스냅 - finalY = snapResult.snappedY; } } - // Size Matching: 다른 요소와 동일한 크기로 스냅 (sizeMatchGuidesEnabled 확인) - if (sizeMatchGuidesEnabled) { - const sizeSnapResult = calculateSizeSnap( - finalWidth, - finalHeight, - otherElements, - elementId, - ); - - if (sizeSnapResult.didSnapWidth) { - // 핸들 방향에 따라 크기 조정 - if (handle.dx === -1) { - // 왼쪽 핸들: 왼쪽 가장자리를 조정 - finalX = finalX - (sizeSnapResult.snappedWidth - finalWidth); - } - finalWidth = sizeSnapResult.snappedWidth; - } - - if (sizeSnapResult.didSnapHeight) { - if (handle.dy === -1) { - // 위쪽 핸들: 위쪽 가장자리를 조정 - finalY = finalY - (sizeSnapResult.snappedHeight - finalHeight); - } - finalHeight = sizeSnapResult.snappedHeight; - } - - // 스냅 후 bounds로 가이드라인 업데이트 - const hasAlignSnap = - (handle.dx !== 0 && - snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled)) || - (handle.dy !== 0 && - snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled)); - const hasSizeSnap = - sizeSnapResult.didSnapWidth || sizeSnapResult.didSnapHeight; - - if (hasAlignSnap || hasSizeSnap) { - const snappedBounds = calculateBounds( - finalX, - finalY, + // 크기 일치 스냅 - 잡은 핸들이 움직이는 축만 (가로 핸들은 높이를 안 바꾼다) + const sizeSnapResult = sizeMatchGuidesEnabled + ? calculateSizeSnap( finalWidth, finalHeight, + otherElements, elementId, - ); - smartGuidesStore.setDraggedBounds(snappedBounds); - - // 정렬 가이드 업데이트 - if (hasAlignSnap) { - smartGuidesStore.setActiveGuides(snapResult.guides); - // 간격 가이드 업데이트 (spacingGuidesEnabled가 true인 경우에만) - if ( - spacingGuidesEnabled && - snapResult.spacingGuides && - snapResult.spacingGuides.length > 0 - ) { - // 핸들 방향에 따라 간격 가이드 필터링 - const filteredSpacingGuides = snapResult.spacingGuides.filter( - (guide) => { - // 수평 방향 간격 가이드 (좌우 간격) - if (guide.direction === 'horizontal') { - // 좌우 핸들이 아니면 표시 안 함 - if (handle.dx === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 표시 - const isDraggedElement = - guide.fromElementId === elementId || - guide.toElementId === elementId; - - if (!isDraggedElement) return false; - - // 왼쪽 핸들(dx: -1): 왼쪽 간격만 표시 - if (handle.dx === -1) { - return guide.toElementId === elementId; - } - // 오른쪽 핸들(dx: 1): 오른쪽 간격만 표시 - if (handle.dx === 1) { - return guide.fromElementId === elementId; - } - } - - // 수직 방향 간격 가이드 (상하 간격) - if (guide.direction === 'vertical') { - // 상하 핸들이 아니면 표시 안 함 - if (handle.dy === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 표시 - const isDraggedElement = - guide.fromElementId === elementId || - guide.toElementId === elementId; + { + matchWidth: handle.dx !== 0 && snapWidthAllowed, + matchHeight: handle.dy !== 0 && snapHeightAllowed, + }, + ) + : null; + if (sizeSnapResult?.didSnapWidth) { + // 핸들 방향에 따라 크기 조정 + if (handle.dx === -1) { + // 왼쪽 핸들: 왼쪽 가장자리를 조정 + finalX = finalX - (sizeSnapResult.snappedWidth - finalWidth); + } + finalWidth = sizeSnapResult.snappedWidth; + } + if (sizeSnapResult?.didSnapHeight) { + if (handle.dy === -1) { + // 위쪽 핸들: 위쪽 가장자리를 조정 + finalY = finalY - (sizeSnapResult.snappedHeight - finalHeight); + } + finalHeight = sizeSnapResult.snappedHeight; + } - if (!isDraggedElement) return false; + // 비율 고정 - 스냅된 기준 축 크기로 배율을 다시 구해 두 축을 함께 놓는다 + // (배율이 범위에 잘리면 그 스냅은 화면에 없으니 가이드도 뺀다) + let aspectSnapDropped = false; + if (aspect) { + const axisHandle = { + dx: Math.sign(handle.dx) as -1 | 0 | 1, + dy: Math.sign(handle.dy) as -1 | 0 | 1, + }; + const startSize = + aspect.primary === 'width' + ? aspect.start.width + : aspect.start.height; + const requestedSize = + aspect.primary === 'width' ? finalWidth : finalHeight; + const requested = requestedSize / startSize; + const exact = { axis: aspect.primary, size: requestedSize }; + const scale = settleAspectScale( + aspect.start, + requested, + axisHandle, + aspect.range, + exact, + ); + aspectSnapDropped = scale !== requested; + const scaled = scaleBoundsAnchored( + aspect.start, + scale, + axisHandle, + exactSizeFor(aspect.start, scale, requested, exact), + ); + finalX = scaled.x; + finalY = scaled.y; + finalWidth = scaled.width; + finalHeight = scaled.height; + } - // 위쪽 핸들(dy: -1): 위쪽 간격만 표시 - if (handle.dy === -1) { - return guide.toElementId === elementId; - } - // 아래쪽 핸들(dy: 1): 아래쪽 간격만 표시 - if (handle.dy === 1) { - return guide.fromElementId === elementId; - } - } + if (aspectSnapDropped) { + appliedSnapX = false; + appliedSnapY = false; + } + const hasAlignSnap = (alignSnapX || alignSnapY) && !aspectSnapDropped; + const hasSizeSnap = + (sizeSnapResult?.didSnapWidth === true || + sizeSnapResult?.didSnapHeight === true) && + !aspectSnapDropped; - return false; - }, - ); - smartGuidesStore.setSpacingGuides(filteredSpacingGuides); - } else { - smartGuidesStore.setSpacingGuides([]); - } - } else { - smartGuidesStore.setActiveGuides([]); - smartGuidesStore.setSpacingGuides([]); - } + if (hasAlignSnap || hasSizeSnap) { + // 스냅 후 bounds로 가이드라인 업데이트 + const snappedBounds = calculateBounds( + finalX, + finalY, + finalWidth, + finalHeight, + elementId, + ); + smartGuidesStore.setDraggedBounds(snappedBounds); - // Size Match 가이드 업데이트 (정렬 스냅과 별개로 항상 표시) - if (hasSizeSnap) { - smartGuidesStore.setSizeMatchGuides( - sizeSnapResult.sizeMatchGuides, - ); - } else { - smartGuidesStore.setSizeMatchGuides([]); - } - } else { - smartGuidesStore.clearGuides(); - } - } else { - // sizeMatchGuides가 비활성화된 경우 기존 정렬 스냅만 처리 - const hasAlignSnap = - (handle.dx !== 0 && - snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled)) || - (handle.dy !== 0 && - snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled)); + // 정렬 가이드 업데이트 - 적용된 축의 가이드만 if (hasAlignSnap) { - const snappedBounds = calculateBounds( - finalX, - finalY, - finalWidth, - finalHeight, - elementId, + smartGuidesStore.setActiveGuides( + snapResult.guides.filter((guide) => + guide.type === 'vertical' ? appliedSnapX : appliedSnapY, + ), ); - smartGuidesStore.setDraggedBounds(snappedBounds); - smartGuidesStore.setActiveGuides(snapResult.guides); - if (spacingGuidesEnabled && snapResult.spacingGuides?.length > 0) { + // 간격 가이드 업데이트 (spacingGuidesEnabled가 true인 경우에만) + if ( + spacingGuidesEnabled && + snapResult.spacingGuides && + snapResult.spacingGuides.length > 0 + ) { // 핸들 방향에 따라 간격 가이드 필터링 const filteredSpacingGuides = snapResult.spacingGuides.filter( (guide) => { // 수평 방향 간격 가이드 (좌우 간격) if (guide.direction === 'horizontal') { - // 좌우 핸들이 아니면 표시 안 함 - if (handle.dx === 0) return false; + // 좌우 핸들이 아니거나 그 축 스냅을 안 썼으면 표시 안 함 + if (handle.dx === 0 || !appliedSnapX) return false; // 드래그 중인 요소와 관련된 가이드만 표시 const isDraggedElement = @@ -439,8 +414,8 @@ export function useGridResize({ // 수직 방향 간격 가이드 (상하 간격) if (guide.direction === 'vertical') { - // 상하 핸들이 아니면 표시 안 함 - if (handle.dy === 0) return false; + // 상하 핸들이 아니거나 그 축 스냅을 안 썼으면 표시 안 함 + if (handle.dy === 0 || !appliedSnapY) return false; // 드래그 중인 요소와 관련된 가이드만 표시 const isDraggedElement = @@ -466,313 +441,17 @@ export function useGridResize({ } else { smartGuidesStore.setSpacingGuides([]); } - smartGuidesStore.setSizeMatchGuides([]); } else { - smartGuidesStore.clearGuides(); + smartGuidesStore.setActiveGuides([]); + smartGuidesStore.setSpacingGuides([]); } - } - } - } - - // 프리뷰 bounds 업데이트 (실제 요소는 업데이트하지 않음) - const previewData = { - x: finalX, - y: finalY, - width: finalWidth, - height: finalHeight, - }; - setPreviewBounds(previewData); - finalBoundsRef.current = previewData; - }; - - // 플러그인 요소 리사이즈 처리 (스마트 가이드 포함) - 프리뷰 모드 - const handlePluginResizePreview = ( - fullId: string, - newBounds: { - x: number; - y: number; - width: number; - height: number; - handle?: ResizeHandle; - suppressSmartSnap?: boolean; - }, - ) => { - const smartGuidesStore = useSmartGuidesStore.getState(); - const gridSettings = useSettingsStore.getState().gridSettings; - const alignmentGuidesEnabled = gridSettings?.alignmentGuides !== false; - const spacingGuidesEnabled = gridSettings?.spacingGuides !== false; - const sizeMatchGuidesEnabled = gridSettings?.sizeMatchGuides !== false; - - let finalX = newBounds.x; - let finalY = newBounds.y; - let finalWidth = newBounds.width; - let finalHeight = newBounds.height; - // 스마트 가이드 계산 - modifier 유지 중에는 일시 해제하고 그리드 스냅만 남긴다 - if (newBounds.suppressSmartSnap) { - useSmartGuidesStore.getState().clearGuides(); - } else if (getOtherElements && alignmentGuidesEnabled) { - const otherElements = getOtherElements(fullId); - - // 리사이즈 중인 요소의 bounds 계산 - const draggedBounds = calculateBounds( - newBounds.x, - newBounds.y, - newBounds.width, - newBounds.height, - fullId, - ); - - const snapResult = calculateSnapPoints( - draggedBounds, - otherElements, - undefined, - { - disableSpacing: !spacingGuidesEnabled, - gridSnapSize: gridSettings?.gridSnapSize ?? 5, - }, - ); - const handle = newBounds.handle; - - if (handle) { - // X축 스냅 (간격 스냅인 경우 spacingGuidesEnabled 확인) - if ( - handle.dx !== 0 && - snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled) - ) { - if (handle.dx === -1) { - const widthDiff = finalX - snapResult.snappedX; - finalX = snapResult.snappedX; - finalWidth = finalWidth + widthDiff; - } else if (handle.dx === 1) { - const snappedRight = snapResult.snappedX + draggedBounds.width; - finalWidth = snappedRight - finalX; - } else if (handle.dx === 0) { - finalX = snapResult.snappedX; - } - } - - // Y축 스냅 (간격 스냅인 경우 spacingGuidesEnabled 확인) - if ( - handle.dy !== 0 && - snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled) - ) { - if (handle.dy === -1) { - const heightDiff = finalY - snapResult.snappedY; - finalY = snapResult.snappedY; - finalHeight = finalHeight + heightDiff; - } else if (handle.dy === 1) { - const snappedBottom = snapResult.snappedY + draggedBounds.height; - finalHeight = snappedBottom - finalY; - } else if (handle.dy === 0) { - finalY = snapResult.snappedY; - } - } - - // Size Matching: 다른 요소와 동일한 크기로 스냅 (sizeMatchGuidesEnabled 확인) - if (sizeMatchGuidesEnabled) { - const sizeSnapResult = calculateSizeSnap( - finalWidth, - finalHeight, - otherElements, - fullId, + // Size Match 가이드 업데이트 (정렬 스냅과 별개로 항상 표시) + smartGuidesStore.setSizeMatchGuides( + hasSizeSnap && sizeSnapResult ? sizeSnapResult.sizeMatchGuides : [], ); - - if (sizeSnapResult.didSnapWidth) { - if (handle.dx === -1) { - finalX = finalX - (sizeSnapResult.snappedWidth - finalWidth); - } - finalWidth = sizeSnapResult.snappedWidth; - } - - if (sizeSnapResult.didSnapHeight) { - if (handle.dy === -1) { - finalY = finalY - (sizeSnapResult.snappedHeight - finalHeight); - } - finalHeight = sizeSnapResult.snappedHeight; - } - - // 스냅 후 가이드라인 업데이트 - const hasAlignSnap = - (handle.dx !== 0 && - snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled)) || - (handle.dy !== 0 && - snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled)); - const hasSizeSnap = - sizeSnapResult.didSnapWidth || sizeSnapResult.didSnapHeight; - - if (hasAlignSnap || hasSizeSnap) { - const snappedBounds = calculateBounds( - finalX, - finalY, - finalWidth, - finalHeight, - fullId, - ); - smartGuidesStore.setDraggedBounds(snappedBounds); - - // 정렬 가이드 업데이트 - if (hasAlignSnap) { - smartGuidesStore.setActiveGuides(snapResult.guides); - if ( - spacingGuidesEnabled && - snapResult.spacingGuides && - snapResult.spacingGuides.length > 0 - ) { - // 핸들 방향에 따라 간격 가이드 필터링 - const filteredSpacingGuides = snapResult.spacingGuides.filter( - (guide) => { - // 수평 방향 간격 가이드 (좌우 간격) - if (guide.direction === 'horizontal') { - // 좌우 핸들이 아니면 표시 안 함 - if (handle.dx === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 표시 - const isDraggedElement = - guide.fromElementId === fullId || - guide.toElementId === fullId; - - if (!isDraggedElement) return false; - - // 왼쪽 핸들(dx: -1): 왼쪽 간격만 표시 - if (handle.dx === -1) { - return guide.toElementId === fullId; - } - // 오른쪽 핸들(dx: 1): 오른쪽 간격만 표시 - if (handle.dx === 1) { - return guide.fromElementId === fullId; - } - } - - // 수직 방향 간격 가이드 (상하 간격) - if (guide.direction === 'vertical') { - // 상하 핸들이 아니면 표시 안 함 - if (handle.dy === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 표시 - const isDraggedElement = - guide.fromElementId === fullId || - guide.toElementId === fullId; - - if (!isDraggedElement) return false; - - // 위쪽 핸들(dy: -1): 위쪽 간격만 표시 - if (handle.dy === -1) { - return guide.toElementId === fullId; - } - // 아래쪽 핸들(dy: 1): 아래쪽 간격만 표시 - if (handle.dy === 1) { - return guide.fromElementId === fullId; - } - } - - return false; - }, - ); - smartGuidesStore.setSpacingGuides(filteredSpacingGuides); - } else { - smartGuidesStore.setSpacingGuides([]); - } - } else { - smartGuidesStore.setActiveGuides([]); - smartGuidesStore.setSpacingGuides([]); - } - - // Size Match 가이드 업데이트 - if (hasSizeSnap) { - smartGuidesStore.setSizeMatchGuides( - sizeSnapResult.sizeMatchGuides, - ); - } else { - smartGuidesStore.setSizeMatchGuides([]); - } - } else { - smartGuidesStore.clearGuides(); - } } else { - // sizeMatchGuides가 비활성화된 경우 기존 정렬 스냅만 처리 - const hasAlignSnap = - (handle.dx !== 0 && - snapResult.didSnapX && - !(snapResult.didSpacingSnapX && !spacingGuidesEnabled)) || - (handle.dy !== 0 && - snapResult.didSnapY && - !(snapResult.didSpacingSnapY && !spacingGuidesEnabled)); - if (hasAlignSnap) { - const snappedBounds = calculateBounds( - finalX, - finalY, - finalWidth, - finalHeight, - fullId, - ); - smartGuidesStore.setDraggedBounds(snappedBounds); - smartGuidesStore.setActiveGuides(snapResult.guides); - if (spacingGuidesEnabled && snapResult.spacingGuides?.length > 0) { - // 핸들 방향에 따라 간격 가이드 필터링 - const filteredSpacingGuides = snapResult.spacingGuides.filter( - (guide) => { - // 수평 방향 간격 가이드 (좌우 간격) - if (guide.direction === 'horizontal') { - // 좌우 핸들이 아니면 표시 안 함 - if (handle.dx === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 필터링 - const isDraggedElement = - guide.fromElementId === fullId || - guide.toElementId === fullId; - - if (isDraggedElement) { - // 왼쪽 핸들(dx: -1): 왼쪽 간격만 표시 - if (handle.dx === -1) { - return guide.toElementId === fullId; - } - // 오른쪽 핸들(dx: 1): 오른쪽 간격만 표시 - if (handle.dx === 1) { - return guide.fromElementId === fullId; - } - } - // 다른 요소들 사이의 참조 간격은 항상 표시 - return !isDraggedElement; - } - - // 수직 방향 간격 가이드 (상하 간격) - if (guide.direction === 'vertical') { - // 상하 핸들이 아니면 표시 안 함 - if (handle.dy === 0) return false; - - // 드래그 중인 요소와 관련된 가이드만 표시 - const isDraggedElement = - guide.fromElementId === fullId || - guide.toElementId === fullId; - - if (!isDraggedElement) return false; - - // 위쪽 핸들(dy: -1): 위쪽 간격만 표시 - if (handle.dy === -1) { - return guide.toElementId === fullId; - } - // 아래쪽 핸들(dy: 1): 아래쪽 간격만 표시 - if (handle.dy === 1) { - return guide.fromElementId === fullId; - } - } - - return false; - }, - ); - smartGuidesStore.setSpacingGuides(filteredSpacingGuides); - } else { - smartGuidesStore.setSpacingGuides([]); - } - smartGuidesStore.setSizeMatchGuides([]); - } else { - smartGuidesStore.clearGuides(); - } + smartGuidesStore.clearGuides(); } } } @@ -796,6 +475,11 @@ export function useGridResize({ height: number; handle?: ResizeHandle; suppressSmartSnap?: boolean; + aspect?: { + start: AspectStartBounds; + primary: AspectPrimaryAxis; + range: ScaleRange; + }; }) => { if (selectedElements.length !== 1) return; @@ -810,7 +494,8 @@ export function useGridResize({ return; } if (frozenTarget?.type === 'plugin') { - handlePluginResizePreview(frozenTarget.id, newBounds); + // 플러그인도 같은 스냅·비율 재유도 경로 - 프리뷰 계산은 요소 종류와 무관하다 + handleElementResizePreview(frozenTarget.id, newBounds); } }; diff --git a/src/renderer/utils/grid/smartGuides.test.ts b/src/renderer/utils/grid/smartGuides.test.ts index 3a9deb117..bd88dcf27 100644 --- a/src/renderer/utils/grid/smartGuides.test.ts +++ b/src/renderer/utils/grid/smartGuides.test.ts @@ -3,6 +3,7 @@ import { CANVAS_CENTER_X, CANVAS_CENTER_Y, calculateBounds, + calculateSizeSnap, calculateSnapPoints, } from './smartGuides'; @@ -85,3 +86,46 @@ describe('calculateSnapPoints 캔버스 중앙 그리드 스냅', () => { }); }); }); + +describe('calculateSizeSnap 활성 축', () => { + const other = { + id: 'other', + left: 1000, + top: 1000, + right: 1100, + bottom: 1050, + centerX: 1050, + centerY: 1025, + width: 100, + height: 50, + }; + + it('기본은 두 축 모두 일치시킨다', () => { + const result = calculateSizeSnap(102, 52, [other]); + expect(result).toMatchObject({ + didSnapWidth: true, + snappedWidth: 100, + didSnapHeight: true, + snappedHeight: 50, + }); + expect(result.sizeMatchGuides.map((guide) => guide.dimension)).toEqual([ + 'width', + 'height', + ]); + }); + + it('잡지 않은 축은 값도 가이드도 손대지 않는다', () => { + const result = calculateSizeSnap(102, 52, [other], '', { + matchHeight: false, + }); + expect(result).toMatchObject({ + didSnapWidth: true, + snappedWidth: 100, + didSnapHeight: false, + snappedHeight: 52, + }); + expect(result.sizeMatchGuides.map((guide) => guide.dimension)).toEqual([ + 'width', + ]); + }); +}); diff --git a/src/renderer/utils/grid/smartGuides.ts b/src/renderer/utils/grid/smartGuides.ts index a11f24454..144cfab72 100644 --- a/src/renderer/utils/grid/smartGuides.ts +++ b/src/renderer/utils/grid/smartGuides.ts @@ -997,11 +997,19 @@ function calculateSpacingGuides( * 다른 요소와 동일한 width/height로 스냅 * 여러 요소가 비슷한 크기일 경우, 가장 가까운 크기를 가진 요소에 스냅 */ +export interface SizeSnapAxes { + /** 너비 일치 허용 - 잡은 핸들이 가로로 움직일 때만 */ + matchWidth?: boolean; + /** 높이 일치 허용 - 잡은 핸들이 세로로 움직일 때만 */ + matchHeight?: boolean; +} + export function calculateSizeSnap( currentWidth: number, currentHeight: number, otherElements: ElementBounds[], draggedId: string = '', + { matchWidth = true, matchHeight = true }: SizeSnapAxes = {}, ): SizeSnapResult { const sizeMatchGuides: SizeMatchGuide[] = []; let snappedWidth = currentWidth; @@ -1019,16 +1027,24 @@ export function calculateSizeSnap( // 첫 번째 패스: 임계값 내에서 가장 가까운 크기를 찾음 for (const other of others) { - // width 일치 체크 - 가장 가까운 것 선택 + // width 일치 체크 - 가장 가까운 것 선택. 잡지 않은 축은 손대지 않는다 const widthDiff = Math.abs(currentWidth - other.width); - if (widthDiff <= SIZE_MATCH_THRESHOLD && widthDiff < closestWidthDiff) { + if ( + matchWidth && + widthDiff <= SIZE_MATCH_THRESHOLD && + widthDiff < closestWidthDiff + ) { closestWidthDiff = widthDiff; closestWidthMatch = other; } // height 일치 체크 - 가장 가까운 것 선택 const heightDiff = Math.abs(currentHeight - other.height); - if (heightDiff <= SIZE_MATCH_THRESHOLD && heightDiff < closestHeightDiff) { + if ( + matchHeight && + heightDiff <= SIZE_MATCH_THRESHOLD && + heightDiff < closestHeightDiff + ) { closestHeightDiff = heightDiff; closestHeightMatch = other; } From 0a0d4644dca12da853fa0c9169a33b0eed92189f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 03:13:04 +0900 Subject: [PATCH 104/125] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EB=A6=AC?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EC=83=81=ED=95=9C=EA=B3=BC=20?= =?UTF-8?q?=EC=9E=A1=EC=9D=80=20=EC=B6=95=20=EA=B8=B0=EC=A4=80=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=ED=81=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/handles/GroupResizeHandles.test.tsx | 171 ++++++++++++++++++ .../main/Grid/handles/GroupResizeHandles.tsx | 70 +++++-- .../Grid/handles/groupResizeUtils.test.ts | 171 ++++++++++++++++++ .../main/Grid/handles/groupResizeUtils.ts | 97 +++++++++- 4 files changed, 487 insertions(+), 22 deletions(-) diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx index 255da560e..68ca9537f 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx @@ -7,6 +7,7 @@ import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; import GroupResizeHandles from './GroupResizeHandles'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; const SPRITE_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const SPRITE_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; @@ -125,4 +126,174 @@ describe('GroupResizeHandles 최소 크기', () => { expect(guides.draggedBounds).toBeNull(); act(() => document.dispatchEvent(new MouseEvent('mouseup'))); }); + // 그룹 리사이즈 한 번을 돌리고 첫 onGroupResize 결과를 돌려준다 + const dragGroup = async ( + spritePositions: Record< + string, + ReturnType[] + >, + handleId: string, + from: { x: number; y: number }, + to: { x: number; y: number }, + getOtherElements: () => SmartGuideElementBounds[] = () => [], + ) => { + const onGroupResize = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + const handle = host.querySelector( + `[data-group-resize-handle="${handleId}"]`, + )!; + act(() => + handle.dispatchEvent( + new MouseEvent('mousedown', { + clientX: from.x, + clientY: from.y, + bubbles: true, + cancelable: true, + }), + ), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { clientX: to.x, clientY: to.y }), + ); + const callback = callbacks.get(1)!; + callbacks.clear(); + act(() => callback(performance.now())); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + return onGroupResize.mock.calls[0][0] as { + groupBounds: { x: number; y: number; width: number; height: number }; + elementBounds: Array<{ + bounds: { x: number; y: number; width: number; height: number }; + }>; + }; + }; + + it('얇은 스프라이트 그룹의 세로 확대는 파생 폭 상한에서 멈춘다', async () => { + // 400x0.1 둘이 세로로 붙은 0.2 높이 그룹 - 아래 핸들 100px는 배율 500 + const result = await dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 400, + height: 0.1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 0, + dy: 0.1, + width: 400, + height: 0.1, + }), + ], + }, + 's', + { x: 200, y: 0 }, + { x: 200, y: 100 }, + ); + expect(result.groupBounds.height).toBeLessThan(100); + for (const { bounds } of result.elementBounds) { + expect(isBoundsWithinEditorLimits(bounds)).toBe(true); + expect(bounds.width / 400).toBeCloseTo(bounds.height / 0.1, 6); + } + expect( + Math.max(...result.elementBounds.map(({ bounds }) => bounds.width)), + ).toBeGreaterThan(32000); + }); + + it('가로 핸들은 그룹 높이 크기 일치를 받지 않는다', async () => { + // 높이 102는 임계값 안, 폭 1000은 밖 - 예전에는 가로 핸들인데 높이가 102로 바뀌었다 + const heightTarget = (): SmartGuideElementBounds => ({ + id: 'other', + left: 1000, + top: 600, + right: 2000, + bottom: 702, + centerX: 1500, + centerY: 651, + width: 1000, + height: 102, + }); + const result = await dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 100, + height: 100, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 200, + dy: 0, + width: 100, + height: 100, + }), + ], + }, + 'e', + { x: 300, y: 50 }, + { x: 290, y: 50 }, + () => [heightTarget()], + ); + expect(result.groupBounds.height).toBe(100); + expect(result.groupBounds.width).toBe(290); + }); + + it('가로 핸들은 얇은 그룹의 높이를 10으로 키우지 않는다', async () => { + // 400x0.1 둘이 세로로 붙은 0.2 높이 그룹 - 잡지 않은 세로축은 시작값 그대로 + const result = await dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 400, + height: 0.1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 0, + dy: 0.1, + width: 400, + height: 0.1, + }), + ], + }, + 'e', + { x: 400, y: 0 }, + { x: 440, y: 0 }, + ); + // 그룹 높이는 자란 스프라이트(0.11 둘)를 감싼 0.21 - 10으로 뛰지 않는다 + expect(result.groupBounds.height).toBeCloseTo(0.21, 9); + expect(result.groupBounds.width).toBeCloseTo(440, 9); + const ys = result.elementBounds.map(({ bounds }) => bounds.y); + // 배율 1.1 로 폭 440, 높이 0.11 - 중심은 그룹 세로 배율 1 이라 y가 거의 제자리 + for (const y of ys) expect(Math.abs(y)).toBeLessThan(0.2); + for (const { bounds } of result.elementBounds) { + expect(bounds.width / 400).toBeCloseTo(bounds.height / 0.1, 6); + } + }); }); diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index 13e5c0ba3..bd2a979ae 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -25,11 +25,15 @@ import { createRafLatestScheduler, type ContinuousInputStrategy, } from '@utils/animation/rafLatestScheduler'; -import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; +import { + EDITOR_BOUNDS_LIMITS, + type CanonicalEditorDocumentV1, +} from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import { isElementResizable, isAspectLockedElement, + limitGroupGrowth, getElementBounds, calculateGroupBounds, elementBoundsChanged, @@ -502,20 +506,20 @@ const GroupResizeHandles = ({ ); } - // 최소 크기 보장 - newGroupWidth = Math.max(MIN_SIZE, newGroupWidth); - newGroupHeight = Math.max(MIN_SIZE, newGroupHeight); - - // 요소 하한 기준 그룹 최소 크기. 스냅이 maxShrink로 잘라 둔 델타를 다시 줄이면 - // 잡지 않은 가장자리를 고정한 채 되돌리고, 되돌린 축을 알려 그 축의 스냅을 무효화한다 - const minGroupWidth = Math.max( - MIN_SIZE, - startGroupBounds.width - maxShrinkX, - ); - const minGroupHeight = Math.max( - MIN_SIZE, - startGroupBounds.height - maxShrinkY, - ); + // 요소 하한 기준 그룹 최소 크기 - 잡은 핸들이 움직이는 축만. 잡지 않은 축은 시작값 + // 그대로 둔다 (얇은 그룹의 높이를 가로 핸들이 10으로 키우면 요소가 밀려난다) + // - 스냅이 maxShrink로 잘라 둔 델타를 다시 줄이면 잡지 않은 가장자리를 고정한 채 + // 되돌리고, 되돌린 축을 알려 그 축의 스냅을 무효화한다 + const minGroupWidth = + handle.dx === 0 + ? startGroupBounds.width + : Math.max(MIN_SIZE, startGroupBounds.width - maxShrinkX); + const minGroupHeight = + handle.dy === 0 + ? startGroupBounds.height + : Math.max(MIN_SIZE, startGroupBounds.height - maxShrinkY); + newGroupWidth = Math.max(minGroupWidth, newGroupWidth); + newGroupHeight = Math.max(minGroupHeight, newGroupHeight); const enforceMinGroupSize = (): { width: boolean; height: boolean } => { const clamped = { width: false, height: false }; if (newGroupWidth < minGroupWidth) { @@ -535,6 +539,34 @@ const GroupResizeHandles = ({ return clamped; }; + // 요소 상한(치수·저장 좌표)까지 한 번에. 후보를 요소에 투영해 넘치면 시작 → + // 후보 진행 배율을 줄인다. 되돌린 축은 하한과 같은 규칙으로 가이드에서 뺀다 + const enforceGroupLimits = (): { width: boolean; height: boolean } => { + const clamped = enforceMinGroupSize(); + const growth = limitGroupGrowth( + startElementBounds, + startGroupBounds, + { + x: newGroupX, + y: newGroupY, + width: newGroupWidth, + height: newGroupHeight, + }, + handle, + EDITOR_BOUNDS_LIMITS, + ); + if (growth.limitedWidth || growth.limitedHeight) { + newGroupX = growth.bounds.x; + newGroupY = growth.bounds.y; + newGroupWidth = growth.bounds.width; + newGroupHeight = growth.bounds.height; + } + return { + width: clamped.width || growth.limitedWidth, + height: clamped.height || growth.limitedHeight, + }; + }; + // === 스마트 가이드 스냅 적용 (그룹 바운딩 박스 기준) === const smartGuidesStore = useSmartGuidesStore.getState(); const gridSettings = useSettingsStore.getState().gridSettings; @@ -616,11 +648,13 @@ const GroupResizeHandles = ({ // Size Matching: 다른 요소와 동일한 크기로 스냅 let sizeSnapResult: SizeSnapResult | null = null; if (sizeMatchGuidesEnabled) { + // 잡은 핸들이 움직이는 축만 - 가로 핸들이 그룹 높이를 바꾸면 안 된다 sizeSnapResult = calculateSizeSnap( newGroupWidth, newGroupHeight, otherElements, 'group', + { matchWidth: handle.dx !== 0, matchHeight: handle.dy !== 0 }, ); if (sizeSnapResult.didSnapWidth) { @@ -642,7 +676,7 @@ const GroupResizeHandles = ({ // 스냅이 요소 하한 아래로 내려갔으면 되돌린다. 되돌린 축의 정렬·간격·크기 일치는 // 화면에서 성립하지 않으므로 가이드에서도 뺀다 - 가이드는 최종 결과를 따라야 한다 - const clamped = enforceMinGroupSize(); + const clamped = enforceGroupLimits(); const alignSnapX = snapResult.didSnapX && !clamped.width; const alignSnapY = snapResult.didSnapY && !clamped.height; const sizeSnapWidth = @@ -754,8 +788,8 @@ const GroupResizeHandles = ({ } } - // 스마트 스냅을 건너뛴 경로도 같은 하한을 지킨다 (스냅 경로에서는 이미 적용돼 무변화) - enforceMinGroupSize(); + // 스마트 스냅을 건너뛴 경로도 같은 하한·상한을 지킨다 (스냅 경로에서는 이미 적용돼 무변화) + enforceGroupLimits(); // 각 리사이즈 가능한 요소에 그룹 변환 투영 (스냅 적용된 그룹 bounds 기준). // 스냅은 그룹 bounds에서만 처리하고, 비율 고정 요소는 단일 배율을 따른다 diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts index 5b360bbd3..b1a5d3ae8 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; + +import { EDITOR_BOUNDS_LIMITS } from '@src/types/editor'; import { + limitGroupGrowth, elementBoundsChanged, projectGroupElementBounds, shrinkLimitSize, @@ -7,6 +10,7 @@ import { type Bounds, type ElementBounds, } from './groupResizeUtils'; +import { isBoundsWithinEditorLimits } from './resizeLimits'; const ID_A = '00000000-0000-0000-0000-000000000001'; const ID_B = '00000000-0000-0000-0000-000000000002'; @@ -91,3 +95,170 @@ describe('projectGroupElementBounds', () => { expect(shrinkLimitSize(key, { ...keyBounds, width: 80 }, 'y')).toBe(60); }); }); + +describe('limitGroupGrowth', () => { + const limits = EDITOR_BOUNDS_LIMITS; + const key = { type: 'key', id: ID_A, index: 0 } as const; + const sprite = { type: 'sprite', id: ID_B, index: 0 } as const; + const bottomHandle = { dx: 0, dy: 1 } as const; + const rightHandle = { dx: 1, dy: 0 } as const; + const leftHandle = { dx: -1, dy: 0 } as const; + + it('상한 안이면 후보를 그대로 돌려준다', () => { + const bounds: Bounds = { x: 0, y: 0, width: 200, height: 125 }; + const candidate: Bounds = { x: 0, y: 0, width: 200, height: 250 }; + expect( + limitGroupGrowth( + [{ element: sprite, bounds }], + { ...bounds }, + candidate, + bottomHandle, + limits, + ), + ).toEqual({ bounds: candidate, limitedWidth: false, limitedHeight: false }); + }); + + it('얇은 스프라이트의 세로 확대는 파생 폭이 32768에 닿는 진행 배율에서 멈춘다', () => { + const bounds: Bounds = { x: 0, y: 0, width: 400, height: 0.1 }; + const start: Bounds = { ...bounds }; + // 세로 배율 100 → 단일 배율로 폭 40000 + const candidate: Bounds = { x: 0, y: 0, width: 400, height: 10 }; + const result = limitGroupGrowth( + [{ element: sprite, bounds }], + start, + candidate, + bottomHandle, + limits, + ); + expect(result.limitedHeight).toBe(true); + expect(result.limitedWidth).toBe(false); + const projected = projectGroupElementBounds( + sprite, + bounds, + start, + result.bounds, + bottomHandle, + ); + expect(isBoundsWithinEditorLimits(projected)).toBe(true); + expect(projected.width).toBeGreaterThan(32767); + expect(projected.width / 400).toBeCloseTo(projected.height / 0.1, 6); + }); + + it('혼합 그룹은 가장 먼저 닿는 제약이 진행을 정한다 (여기서는 키의 저장 좌표)', () => { + const spriteBounds: Bounds = { x: 0, y: 0, width: 400, height: 0.1 }; + const keyBounds: Bounds = { x: 500, y: 0, width: 100, height: 100 }; + const elements = [ + { element: sprite, bounds: spriteBounds }, + { element: key, bounds: keyBounds }, + ]; + const start: Bounds = { x: 0, y: 0, width: 600, height: 100 }; + // 가로 배율 1000. 스프라이트 폭은 81.92배, 키 x=500은 65.536배에서 32768에 닿는다 + const candidate: Bounds = { x: 0, y: 0, width: 600000, height: 100 }; + const result = limitGroupGrowth( + elements, + start, + candidate, + rightHandle, + limits, + ); + const projected = elements.map(({ element, bounds }) => + projectGroupElementBounds( + element, + bounds, + start, + result.bounds, + rightHandle, + ), + ); + expect(projected.every(isBoundsWithinEditorLimits)).toBe(true); + expect(projected[1].x).toBeGreaterThan(32767); + expect(projected[0].width).toBeCloseTo(400 * 65.536, 3); + }); + + it('저장 좌표 상한도 진행을 막는다', () => { + // 왼쪽 핸들 확대는 요소 x를 -32768 아래로 민다 + const bounds: Bounds = { x: -32000, y: 0, width: 100, height: 100 }; + const start: Bounds = { ...bounds }; + const candidate: Bounds = { x: -34000, y: 0, width: 2100, height: 100 }; + const result = limitGroupGrowth( + [{ element: key, bounds }], + start, + candidate, + leftHandle, + limits, + ); + const projected = projectGroupElementBounds( + key, + bounds, + start, + result.bounds, + leftHandle, + ); + expect(projected.x).toBeGreaterThanOrEqual(-32768); + expect(projected.x).toBeLessThan(-32700); + expect(result.limitedWidth).toBe(true); + }); + + it('범위 밖 legacy 요소가 있어도 그 항목을 건드리지 않는 직교축 리사이즈는 통과한다', () => { + // 폭 40000 legacy 키 + 정상 키. 아래 핸들로 높이만 두 배 + const legacyBounds: Bounds = { x: 0, y: 0, width: 40000, height: 100 }; + const keyBounds: Bounds = { x: 0, y: 100, width: 100, height: 100 }; + const elements = [ + { + element: { type: 'key', id: ID_A, index: 0 } as const, + bounds: legacyBounds, + }, + { element: key, bounds: keyBounds }, + ]; + const start: Bounds = { x: 0, y: 0, width: 40000, height: 200 }; + const candidate: Bounds = { x: 0, y: 0, width: 40000, height: 400 }; + const result = limitGroupGrowth( + elements, + start, + candidate, + bottomHandle, + limits, + ); + expect(result).toEqual({ + bounds: candidate, + limitedWidth: false, + limitedHeight: false, + }); + }); + + it('안 움직인 축은 극단 좌표에서도 요소 값을 비트 단위로 보존한다', () => { + // 되돌린 뺄셈·덧셈이 1ulp 흔들리면 범위 밖 legacy x가 커진 것으로 거부되던 반례 + const bounds: Bounds = { + x: 50577.53549435205, + y: 0, + width: 100, + height: 100, + }; + const start: Bounds = { + x: -38245.44619570407, + y: 0, + width: 88922.98169005613, + height: 100, + }; + const candidate: Bounds = { ...start, height: 200 }; + const projected = projectGroupElementBounds( + key, + bounds, + start, + candidate, + bottomHandle, + ); + expect(projected.x).toBe(bounds.x); + expect(projected.width).toBe(bounds.width); + expect(projected.height).toBe(200); + expect( + limitGroupGrowth( + [{ element: key, bounds }], + start, + candidate, + bottomHandle, + limits, + ), + ).toEqual({ bounds: candidate, limitedWidth: false, limitedHeight: false }); + }); +}); diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts index 2f24a9c04..320c4126c 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts @@ -8,6 +8,8 @@ import type { CanonicalEditorDocumentV1 } from '@src/types/editor'; import type { PluginDisplayElementInternal } from '@src/types/plugin/api'; import type { SelectedElement } from '@stores/grid/useGridSelectionStore'; +import { isBoundsTransitionWithinEditorLimits } from './resizeLimits'; + export type { SelectedElement }; // ===== 타입 정의 ===== @@ -86,17 +88,22 @@ export function projectGroupElementBounds( ): Bounds { const scaleX = groupScaleRatio(startGroup.width, nextGroup.width); const scaleY = groupScaleRatio(startGroup.height, nextGroup.height); + // 그룹이 안 움직인 축은 요소 값을 그대로 둔다 - 되돌린 뺄셈·덧셈이 극단 좌표에서 + // 1ulp 흔들리면 범위 밖 legacy 좌표가 "커진" 것으로 거부된다 + const stillX = scaleX === 1 && nextGroup.x === startGroup.x; + const stillY = scaleY === 1 && nextGroup.y === startGroup.y; const relativeX = bounds.x - startGroup.x; const relativeY = bounds.y - startGroup.y; if (!isAspectLockedElement(element)) { return { - x: nextGroup.x + relativeX * scaleX, - y: nextGroup.y + relativeY * scaleY, - width: bounds.width * scaleX, - height: bounds.height * scaleY, + x: stillX ? bounds.x : nextGroup.x + relativeX * scaleX, + y: stillY ? bounds.y : nextGroup.y + relativeY * scaleY, + width: stillX ? bounds.width : bounds.width * scaleX, + height: stillY ? bounds.height : bounds.height * scaleY, }; } const scale = uniformGroupScale(scaleX, scaleY, handle); + if (scale === 1 && stillX && stillY) return { ...bounds }; const width = bounds.width * scale; const height = bounds.height * scale; const centerX = nextGroup.x + (relativeX + bounds.width / 2) * scaleX; @@ -109,6 +116,88 @@ export function projectGroupElementBounds( }; } +export interface GroupGrowthLimit { + bounds: Bounds; + /** 후보보다 줄어든 축 - 그 축의 스냅 가이드는 화면에서 성립하지 않는다 */ + limitedWidth: boolean; + limitedHeight: boolean; +} + +const lerpBounds = (start: Bounds, end: Bounds, t: number): Bounds => ({ + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + width: start.width + (end.width - start.width) * t, + height: start.height + (end.height - start.height) * t, +}); + +/** + * 그룹 후보를 요소들에 투영해 하나라도 백엔드 상한(치수·저장 좌표)을 넘으면 + * 시작 → 후보 선형 경로의 진행 배율 t를 줄인다. 경로가 선형이면 투영된 x·y·치수가 + * 전부 t에 선형이라(단일 배율 선택 축도 t에 불변) 제약마다 t*가 닫힌 꼴로 나온다 + * - 모서리 핸들은 크기(단일 배율)와 중심(축별 배율)이 결합돼 축별 델타 상한으로는 + * 증명이 안 되므로 최종 투영으로 판정한다. 부동소수 잔차는 같은 predicate로 + * 재검증하고 무효면 [0, t]를 이분 탐색한다 (t=0은 시작 상태라 항상 유효) + * - 전이 규칙을 미러하므로 범위 밖 legacy 요소는 그 항목을 키우지 않는 한 통과한다 + */ +export function limitGroupGrowth( + elements: ElementBounds[], + startGroup: Bounds, + candidate: Bounds, + handle: GroupResizeAxis, + limits: { maxDimension: number; maxAbsCoordinate: number }, +): GroupGrowthLimit { + const project = (group: Bounds): Bounds[] => + elements.map(({ element, bounds }) => + projectGroupElementBounds(element, bounds, startGroup, group, handle), + ); + // 요소마다 시작 bounds를 현재값으로 두는 전이 판정 - 범위 밖 legacy 요소가 있어도 + // 그 항목을 건드리지 않는 직교축 리사이즈는 막히지 않는다 (t=0은 항상 유효) + const allValid = (group: Bounds): boolean => + project(group).every((bounds, index) => + isBoundsTransitionWithinEditorLimits(elements[index].bounds, bounds), + ); + const unchanged = { + bounds: candidate, + limitedWidth: false, + limitedHeight: false, + }; + if (allValid(candidate)) return unchanged; + + // 제약마다 g(t) = g0 + t·(g1 - g0) 가 한계에 닿는 t* + let t = 1; + const tighten = (g0: number, g1: number, limit: number) => { + if (g1 > limit && g1 !== g0) t = Math.min(t, (limit - g0) / (g1 - g0)); + }; + const starts = project(startGroup); + const ends = project(candidate); + starts.forEach((g0, index) => { + const g1 = ends[index]; + tighten(g0.x, g1.x, limits.maxAbsCoordinate); + tighten(-g0.x, -g1.x, limits.maxAbsCoordinate); + tighten(g0.y, g1.y, limits.maxAbsCoordinate); + tighten(-g0.y, -g1.y, limits.maxAbsCoordinate); + tighten(g0.width, g1.width, limits.maxDimension); + tighten(g0.height, g1.height, limits.maxDimension); + }); + t = Math.max(0, Math.min(1, t)); + if (!allValid(lerpBounds(startGroup, candidate, t))) { + let lo = 0; + let hi = t; + for (let step = 0; step < 40; step += 1) { + const mid = (lo + hi) / 2; + if (allValid(lerpBounds(startGroup, candidate, mid))) lo = mid; + else hi = mid; + } + t = lo; + } + const bounds = lerpBounds(startGroup, candidate, t); + return { + bounds, + limitedWidth: bounds.width !== candidate.width, + limitedHeight: bounds.height !== candidate.height, + }; +} + /** * 축소 한계 계산에 쓸 요소 치수. 비율 고정 요소는 어느 축을 끌어도 짧은 변이 * 함께 줄어드므로 짧은 변으로 잰다 From 4bdb9d297a008260cfbb46902847c0ab08e20a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 08:50:23 +0900 Subject: [PATCH 105/125] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C=20=ED=95=98=ED=95=9C=EC=9D=98=20=ED=88=AC=EC=98=81=20?= =?UTF-8?q?=EB=B0=98=EC=98=AC=EB=A6=BC=EA=B3=BC=20=EC=96=87=EC=9D=80=20?= =?UTF-8?q?=EC=B6=95=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Grid/handles/GroupResizeHandles.test.tsx | 356 +++++++++++++++++- .../main/Grid/handles/GroupResizeHandles.tsx | 99 +++-- .../Grid/handles/groupResizeUtils.test.ts | 7 + .../main/Grid/handles/groupResizeUtils.ts | 12 +- 4 files changed, 428 insertions(+), 46 deletions(-) diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx index 68ca9537f..40d0d1d66 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.test.tsx @@ -4,8 +4,10 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeCanonicalSpritePosition } from '@utils/sprite/spriteFixtures'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; import type { ElementBounds as SmartGuideElementBounds } from '@utils/grid/smartGuides'; import { useSmartGuidesStore } from '@stores/grid/useSmartGuidesStore'; +import { useSettingsStore } from '@stores/useSettingsStore'; import GroupResizeHandles from './GroupResizeHandles'; import { isBoundsWithinEditorLimits } from './resizeLimits'; @@ -30,8 +32,10 @@ describe('GroupResizeHandles 최소 크기', () => { let host: HTMLDivElement; let root: Root; let callbacks: Map; + let initialSnapSize: number; beforeEach(() => { + initialSnapSize = useSettingsStore.getState().gridSettings.gridSnapSize; callbacks = new Map(); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callbacks.set(1, callback); @@ -49,6 +53,9 @@ describe('GroupResizeHandles 최소 크기', () => { host.remove(); vi.unstubAllGlobals(); vi.restoreAllMocks(); + useSettingsStore.setState((state) => ({ + gridSettings: { ...state.gridSettings, gridSnapSize: initialSnapSize }, + })); }); it('크기 스냅이 그룹을 더 줄여도 요소는 10px 밑으로 내려가지 않는다', async () => { @@ -177,14 +184,355 @@ describe('GroupResizeHandles 최소 크기', () => { callbacks.clear(); act(() => callback(performance.now())); act(() => document.dispatchEvent(new MouseEvent('mouseup'))); - return onGroupResize.mock.calls[0][0] as { - groupBounds: { x: number; y: number; width: number; height: number }; - elementBounds: Array<{ + const result = onGroupResize.mock.lastCall?.[0]; + return { + groupBounds: result?.groupBounds, + elementBounds: (result?.elementBounds ?? + spritePositions['4key'].map((position) => ({ + bounds: { + x: position.dx, + y: position.dy, + width: position.width, + height: position.height, + }, + }))) as Array<{ bounds: { x: number; y: number; width: number; height: number }; - }>; + }>, + previewCount: onGroupResize.mock.calls.length, }; }; + it('그룹 축소도 투영 반올림으로 요소 하한 아래에 저장하지 않는다', async () => { + const result = await dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 77, + height: 77, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 308, + dy: 0, + width: 77, + height: 77, + }), + ], + }, + 'e', + { x: 385, y: 0 }, + { x: 0, y: 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBeCloseTo(10, 9); + } + }); + + it('한 축이 얇아도 그룹 축소는 스프라이트의 정상 축 하한을 지킨다', async () => { + const result = await dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 400, + height: 0.1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 1600, + dy: 0, + width: 400, + height: 0.1, + }), + ], + }, + 'e', + { x: 2000, y: 0 }, + { x: 0, y: 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBeCloseTo(10, 9); + expect(bounds.width / 400).toBeCloseTo(bounds.height / 0.1, 9); + } + }); + + it('8방향과 스냅별 연속 축소는 최소 크기와 고정 가장자리를 보존한다', async () => { + const handles = [ + { id: 'nw', dx: -1, dy: -1 }, + { id: 'n', dx: 0, dy: -1 }, + { id: 'ne', dx: 1, dy: -1 }, + { id: 'w', dx: -1, dy: 0 }, + { id: 'e', dx: 1, dy: 0 }, + { id: 'sw', dx: -1, dy: 1 }, + { id: 's', dx: 0, dy: 1 }, + { id: 'se', dx: 1, dy: 1 }, + ]; + for (const snapSize of [0, 1, 5, 10]) { + useSettingsStore.setState((state) => ({ + gridSettings: { ...state.gridSettings, gridSnapSize: snapSize }, + })); + for (const handle of handles) { + let sprites = [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 3, + dy: 7, + width: 77, + height: 77, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 311, + dy: 315, + width: 77, + height: 77, + }), + ]; + for (let round = 0; round < 3; round += 1) { + const result = await dragGroup( + { '4key': sprites }, + handle.id, + { x: 0, y: 0 }, + { x: -1000 * handle.dx, y: -1000 * handle.dy }, + ); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThanOrEqual(10); + expect(bounds.width).toBe(bounds.height); + } + if (result.groupBounds && handle.dx !== 0 && handle.dy !== 0) { + const beforeX = Math.min(...sprites.map((sprite) => sprite.dx)); + const beforeY = Math.min(...sprites.map((sprite) => sprite.dy)); + const beforeRight = Math.max( + ...sprites.map((sprite) => sprite.dx + sprite.width), + ); + const beforeBottom = Math.max( + ...sprites.map((sprite) => sprite.dy + sprite.height), + ); + expect( + handle.dx === 1 + ? result.groupBounds.x + : result.groupBounds.x + result.groupBounds.width, + ).toBeCloseTo(handle.dx === 1 ? beforeX : beforeRight, 9); + expect( + handle.dy === 1 + ? result.groupBounds.y + : result.groupBounds.y + result.groupBounds.height, + ).toBeCloseTo(handle.dy === 1 ? beforeY : beforeBottom, 9); + } + sprites = sprites.map((sprite, index) => ({ + ...sprite, + dx: result.elementBounds[index].bounds.x, + dy: result.elementBounds[index].bounds.y, + width: result.elementBounds[index].bounds.width, + height: result.elementBounds[index].bounds.height, + })); + } + } + } + }); + + it('이미 두 축이 얇은 그룹을 줄여도 강제로 키우거나 더 줄이지 않는다', async () => { + const result = await dragGroup( + { + '4key': [ + makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 1, + height: 1, + }), + makeCanonicalSpritePosition({ + id: SPRITE_B, + dx: 2, + dy: 0, + width: 1, + height: 1, + }), + ], + }, + 'e', + { x: 3, y: 0 }, + { x: -100, y: 0 }, + ); + expect(result.previewCount).toBe(0); + }); + + it.each([ + { id: 'e', dx: 1, dy: 0 }, + { id: 's', dx: 0, dy: 1 }, + { id: 'se', dx: 1, dy: 1 }, + ])( + '두 축이 얇은 스프라이트와 큰 키를 함께 $id 방향으로 줄이면 크기를 유지하고 확대는 허용한다', + async ({ id, dx, dy }) => { + useSettingsStore.setState((state) => ({ + gridSettings: { ...state.gridSettings, gridSnapSize: 0 }, + })); + const onGroupResize = + vi.fn< + NonNullable< + React.ComponentProps['onGroupResize'] + > + >(); + const onGroupResizeStart = vi.fn(); + const onGroupResizeEnd = vi.fn(); + const sprite = makeCanonicalSpritePosition({ + id: SPRITE_A, + dx: 0, + dy: 0, + width: 1, + height: 2, + }); + const key = { + ...createDefaultKeyPosition(100, 100), + id: SPRITE_B, + width: 100, + height: 100, + }; + await act(async () => { + root.render( + []} + onGroupResize={onGroupResize} + onGroupResizeStart={onGroupResizeStart} + onGroupResizeEnd={onGroupResizeEnd} + />, + ); + }); + const handle = host.querySelector( + `[data-group-resize-handle="${id}"]`, + )!; + act(() => { + handle.dispatchEvent( + new MouseEvent('mousedown', { + clientX: 0, + clientY: 0, + bubbles: true, + cancelable: true, + }), + ); + }); + const moveAndFlush = (distance: number) => { + document.dispatchEvent( + new MouseEvent('mousemove', { + clientX: dx * distance, + clientY: dy * distance, + }), + ); + const callback = callbacks.get(1)!; + callbacks.clear(); + act(() => callback(performance.now())); + }; + + moveAndFlush(-100); + expect(onGroupResize).not.toHaveBeenCalled(); + expect(onGroupResizeStart).not.toHaveBeenCalled(); + expect(onGroupResizeEnd).not.toHaveBeenCalled(); + + moveAndFlush(50); + expect(onGroupResize).toHaveBeenCalledTimes(1); + expect(onGroupResizeStart).toHaveBeenCalledTimes(1); + const result = onGroupResize.mock.lastCall![0]; + const spriteBounds = result.elementBounds.find( + ({ element }) => element.id === SPRITE_A, + )!.bounds; + const keyBounds = result.elementBounds.find( + ({ element }) => element.id === SPRITE_B, + )!.bounds; + expect(spriteBounds.width).toBe(1.25); + expect(spriteBounds.height).toBe(2.5); + expect(keyBounds.width).toBe(dx === 0 ? 100 : 125); + expect(keyBounds.height).toBe(dy === 0 ? 100 : 125); + expect( + result.elementBounds.every(({ bounds }) => + isBoundsWithinEditorLimits(bounds), + ), + ).toBe(true); + act(() => document.dispatchEvent(new MouseEvent('mouseup'))); + expect(onGroupResizeEnd).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['s', 'e'])( + '얇은 그룹의 %s 핸들도 정상 축이 하한에 닿기 전까지 축소한다', + async (handleId) => { + useSettingsStore.setState((state) => ({ + gridSettings: { ...state.gridSettings, gridSnapSize: 0 }, + })); + const vertical = handleId === 's'; + const result = await dragGroup( + { + '4key': [SPRITE_A, SPRITE_B].map((id, index) => + makeCanonicalSpritePosition({ + id, + dx: vertical ? 0 : index * 0.1, + dy: vertical ? index * 0.1 : 0, + width: vertical ? 400 : 0.1, + height: vertical ? 0.1 : 400, + }), + ), + }, + handleId, + { x: 0, y: 0 }, + { x: vertical ? 0 : -0.1, y: vertical ? -0.1 : 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(vertical ? bounds.width : bounds.height).toBeCloseTo(200, 9); + expect(vertical ? bounds.height : bounds.width).toBeCloseTo(0.05, 9); + } + }, + ); + + it('극소 두께 그룹도 하한 반올림 보정이 멈추지 않고 끝난다', async () => { + useSettingsStore.setState((state) => ({ + gridSettings: { ...state.gridSettings, gridSnapSize: 0 }, + })); + const result = await dragGroup( + { + '4key': [SPRITE_A, SPRITE_B].map((id, index) => + makeCanonicalSpritePosition({ + id, + dx: 0, + dy: index * 1e-320, + width: 400, + height: 1e-320, + }), + ), + }, + 'e', + { x: 0, y: 0 }, + { x: -390, y: 0 }, + ); + expect(result.previewCount).toBe(1); + for (const { bounds } of result.elementBounds) { + expect(bounds.width).toBeGreaterThanOrEqual(10); + expect(bounds.height).toBeGreaterThan(0); + } + }); + it('얇은 스프라이트 그룹의 세로 확대는 파생 폭 상한에서 멈춘다', async () => { // 400x0.1 둘이 세로로 붙은 0.2 높이 그룹 - 아래 핸들 100px는 배율 500 const result = await dragGroup( diff --git a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx index bd2a979ae..6acdbcd38 100644 --- a/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx +++ b/src/renderer/components/main/Grid/handles/GroupResizeHandles.tsx @@ -101,8 +101,8 @@ interface ResizeState { startGroupBounds: Bounds | null; startElementBounds: ElementBounds[]; nonResizableElementBounds?: ElementBounds[]; - maxShrinkX: number; - maxShrinkY: number; + minGroupWidth: number; + minGroupHeight: number; handle?: HandleDef; } @@ -295,8 +295,8 @@ const GroupResizeHandles = ({ startMouseY: 0, startGroupBounds: null, startElementBounds: [], - maxShrinkX: 0, - maxShrinkY: 0, + minGroupWidth: 0, + minGroupHeight: 0, }); const activeResizeCleanupRef = useRef<(() => void) | null>(null); @@ -369,23 +369,40 @@ const GroupResizeHandles = ({ ), ); - const getMaxShrink = ( + const getMinGroupSize = ( boundsList: ElementBounds[], groupSize: number, axis: 'x' | 'y', ): number => { - if (!Number.isFinite(groupSize) || groupSize <= 0) return 0; + if (!Number.isFinite(groupSize) || groupSize <= 0) return groupSize; let minScale = 0; + const guardedSizes: number[] = []; for (const { element, bounds } of boundsList) { - const size = shrinkLimitSize(element, bounds, axis); + const size = shrinkLimitSize(element, bounds, axis, MIN_SIZE); if (!Number.isFinite(size) || size <= 0) continue; if (size >= MIN_SIZE) { + guardedSizes.push(size); minScale = Math.max(minScale, MIN_SIZE / size); + } else if (isAspectLockedElement(element)) { + minScale = 1; } } - const groupMinScale = MIN_SIZE / groupSize; - minScale = Math.min(1, Math.max(minScale, groupMinScale)); - return groupSize * (1 - minScale); + // 그룹 자체 10px 하한은 얇은 스프라이트의 정상 축소까지 막으므로 + // 보호할 요소 축에서만 유도하고 보호 축이 없으면 현재 크기 유지 + minScale = minScale > 0 ? Math.min(1, minScale) : 1; + let minGroupSize = groupSize * minScale; + // 그룹 크기에서 배율을 다시 나눠 요소에 곱하는 실제 투영까지 하한 보장 + while ( + guardedSizes.some( + (size) => size * (minGroupSize / groupSize) < MIN_SIZE, + ) + ) { + minGroupSize += Math.max( + Number.MIN_VALUE, + minGroupSize * Number.EPSILON, + ); + } + return minGroupSize; }; resizeRef.current = { @@ -401,8 +418,16 @@ const GroupResizeHandles = ({ }, startElementBounds: resizableElementBounds, nonResizableElementBounds: nonResizableElementBounds, - maxShrinkX: getMaxShrink(resizableElementBounds, groupData.width, 'x'), - maxShrinkY: getMaxShrink(resizableElementBounds, groupData.height, 'y'), + minGroupWidth: getMinGroupSize( + resizableElementBounds, + groupData.width, + 'x', + ), + minGroupHeight: getMinGroupSize( + resizableElementBounds, + groupData.height, + 'y', + ), handle, }; @@ -419,8 +444,8 @@ const GroupResizeHandles = ({ startGroupBounds, startElementBounds, nonResizableElementBounds, - maxShrinkX, - maxShrinkY, + minGroupWidth: minimumWidth, + minGroupHeight: minimumHeight, } = resizeRef.current; if (!handle || !startGroupBounds) return; @@ -459,10 +484,18 @@ const GroupResizeHandles = ({ let snappedDeltaY = handle.dy !== 0 ? snapDelta(rawDeltaY) : 0; if (handle.dx !== 0) { - snappedDeltaX = clampShrinkDelta(snappedDeltaX, handle.dx, maxShrinkX); + snappedDeltaX = clampShrinkDelta( + snappedDeltaX, + handle.dx, + startGroupBounds.width - minimumWidth, + ); } if (handle.dy !== 0) { - snappedDeltaY = clampShrinkDelta(snappedDeltaY, handle.dy, maxShrinkY); + snappedDeltaY = clampShrinkDelta( + snappedDeltaY, + handle.dy, + startGroupBounds.height - minimumHeight, + ); } // 새 그룹 bounds 계산 @@ -474,52 +507,40 @@ const GroupResizeHandles = ({ // 핸들 방향에 따라 크기 조정 if (handle.dx === -1) { newGroupWidth = Math.max( - MIN_SIZE, + minimumWidth, startGroupBounds.width - snappedDeltaX, ); - if (newGroupWidth > MIN_SIZE) { - newGroupX = startGroupBounds.x + snappedDeltaX; - } else { - newGroupX = startGroupBounds.x + startGroupBounds.width - MIN_SIZE; - } + newGroupX = + startGroupBounds.x + (startGroupBounds.width - newGroupWidth); } else if (handle.dx === 1) { newGroupWidth = Math.max( - MIN_SIZE, + minimumWidth, startGroupBounds.width + snappedDeltaX, ); } if (handle.dy === -1) { newGroupHeight = Math.max( - MIN_SIZE, + minimumHeight, startGroupBounds.height - snappedDeltaY, ); - if (newGroupHeight > MIN_SIZE) { - newGroupY = startGroupBounds.y + snappedDeltaY; - } else { - newGroupY = startGroupBounds.y + startGroupBounds.height - MIN_SIZE; - } + newGroupY = + startGroupBounds.y + (startGroupBounds.height - newGroupHeight); } else if (handle.dy === 1) { newGroupHeight = Math.max( - MIN_SIZE, + minimumHeight, startGroupBounds.height + snappedDeltaY, ); } // 요소 하한 기준 그룹 최소 크기 - 잡은 핸들이 움직이는 축만. 잡지 않은 축은 시작값 // 그대로 둔다 (얇은 그룹의 높이를 가로 핸들이 10으로 키우면 요소가 밀려난다) - // - 스냅이 maxShrink로 잘라 둔 델타를 다시 줄이면 잡지 않은 가장자리를 고정한 채 + // - 스마트 스냅이 하한을 다시 넘으면 잡지 않은 가장자리를 고정한 채 // 되돌리고, 되돌린 축을 알려 그 축의 스냅을 무효화한다 const minGroupWidth = - handle.dx === 0 - ? startGroupBounds.width - : Math.max(MIN_SIZE, startGroupBounds.width - maxShrinkX); + handle.dx === 0 ? startGroupBounds.width : minimumWidth; const minGroupHeight = - handle.dy === 0 - ? startGroupBounds.height - : Math.max(MIN_SIZE, startGroupBounds.height - maxShrinkY); - newGroupWidth = Math.max(minGroupWidth, newGroupWidth); - newGroupHeight = Math.max(minGroupHeight, newGroupHeight); + handle.dy === 0 ? startGroupBounds.height : minimumHeight; const enforceMinGroupSize = (): { width: boolean; height: boolean } => { const clamped = { width: false, height: false }; if (newGroupWidth < minGroupWidth) { diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts index b1a5d3ae8..d7dd031b5 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.test.ts @@ -94,6 +94,13 @@ describe('projectGroupElementBounds', () => { expect(shrinkLimitSize(key, { ...keyBounds, width: 80 }, 'x')).toBe(80); expect(shrinkLimitSize(key, { ...keyBounds, width: 80 }, 'y')).toBe(60); }); + + it('비율 고정 요소에 이미 얇은 축이 있으면 정상 축을 하한 기준으로 쓴다', () => { + const thin = { ...spriteBounds, width: 400, height: 0.1 }; + expect(shrinkLimitSize(sprite, thin, 'x', 10)).toBe(400); + expect(shrinkLimitSize(sprite, thin, 'y', 10)).toBe(400); + expect(shrinkLimitSize(sprite, { ...thin, width: 1 }, 'x', 10)).toBe(0.1); + }); }); describe('limitGroupGrowth', () => { diff --git a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts index 320c4126c..bbd0d6725 100644 --- a/src/renderer/components/main/Grid/handles/groupResizeUtils.ts +++ b/src/renderer/components/main/Grid/handles/groupResizeUtils.ts @@ -199,16 +199,22 @@ export function limitGroupGrowth( } /** - * 축소 한계 계산에 쓸 요소 치수. 비율 고정 요소는 어느 축을 끌어도 짧은 변이 - * 함께 줄어드므로 짧은 변으로 잰다 + * 축소 한계 계산에 쓸 요소 치수. 비율 고정 요소는 어느 축을 끌어도 함께 + * 줄어드는 보호 축 중 짧은 변으로 잰다 */ export function shrinkLimitSize( element: SelectedElement, bounds: Bounds, axis: 'x' | 'y', + minSize = 0, ): number { if (isAspectLockedElement(element)) { - return Math.min(bounds.width, bounds.height); + const guarded = [bounds.width, bounds.height].filter( + (size) => size >= minSize, + ); + return guarded.length > 0 + ? Math.min(...guarded) + : Math.min(bounds.width, bounds.height); } return axis === 'x' ? bounds.width : bounds.height; } From 27b91e4848611db374f89f64d69ec98a8df23a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 08:50:23 +0900 Subject: [PATCH 106/125] =?UTF-8?q?fix:=20=EB=8B=B4=EB=8B=B9=20=ED=82=A4?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95=20=EC=A0=84=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EC=B2=AB=20=EC=83=81=ED=83=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EC=A4=80=20=ED=81=AC=EA=B8=B0=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../single/SingleSpritePanel.test.tsx | 243 ++++++++++++++++++ .../single/SingleSpritePanel.tsx | 148 +++++++---- .../Grid/layers/SpriteItem.render.test.tsx | 30 +++ .../main/Grid/layers/SpriteItem.tsx | 18 +- .../stores/grid/useSpriteEditPreviewStore.ts | 8 +- 5 files changed, 396 insertions(+), 51 deletions(-) diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx index 36308d02a..6c75539ea 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.test.tsx @@ -12,6 +12,10 @@ import { } from '@stores/grid/useSpritePoseHandleStore'; import { makeSpritePose } from '@utils/sprite/spriteFixtures'; import { projectSpriteResize } from '@utils/sprite/resizeProjection'; +import { + placeSpriteVisual, + spritePoseVisual, +} from '@utils/sprite/spritePlacement'; import type { CanonicalReactiveSpritePosition } from '@src/types/editor'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -1891,6 +1895,245 @@ describe('SingleSpritePanel 자세 편집', () => { expect(generatePatch(spritePosition({ poses: [] }))).toBeNull(); }); + it('담당 키보다 먼저 고른 첫 상태 이미지의 기준 크기를 후속 저장에 보존한다', async () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'first-pose.png', + }); + const pickButton = posePopup()!.querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )!; + await act(async () => pickButton.click()); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect( + useSpriteEditPreviewStore.getState().preview?.referenceNaturalSize, + ).toEqual({ source: null, width: 64, height: 32 }); + + await act(async () => + triggerDropdownByText('propertiesPanel.spriteTriggerPlaceholder').click(), + ); + await act(async () => menuOptionByText('A').click()); + + expect(mocks.patchPosition).toHaveBeenCalledOnce(); + const generatePatch = mocks.patchPosition.mock.calls[0][4] as ( + current: CanonicalReactiveSpritePosition, + ) => Partial; + expect(generatePatch(position).referenceNaturalSize).toEqual({ + source: null, + width: 64, + height: 32, + }); + const restored = JSON.parse( + JSON.stringify({ + ...position, + ...generatePatch(position), + }), + ) as CanonicalReactiveSpritePosition; + const secondPose = makeSpritePose({ + imageOverride: 'second-pose.png', + imageOverrideMetrics: { + source: 'second-pose.png', + width: 32, + height: 32, + }, + }); + expect( + placeSpriteVisual(restored, spritePoseVisual(restored, secondPose)).rect, + ).toEqual({ x: 50, y: 0, width: 100, height: 150 }); + + // 대기 중 기본 이미지나 다른 상태가 기준 크기를 먼저 저장했으면 최신값 유지 + expect( + generatePatch( + spritePosition({ + baseImage: 'base.png', + referenceNaturalSize: { source: 'base.png', width: 200, height: 100 }, + }), + ), + ).not.toHaveProperty('referenceNaturalSize'); + expect( + generatePatch( + spritePosition({ + referenceNaturalSize: { source: null, width: 120, height: 90 }, + }), + ), + ).not.toHaveProperty('referenceNaturalSize'); + }); + + it('미저장 첫 이미지 기준 크기는 다른 상태 이미지와 핸들에도 같은 배율을 준다', async () => { + const position = spritePosition(); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'first-pose.png', + }); + await act(async () => + posePopup()! + .querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )! + .click(), + ); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'second-pose.png', + }); + mocks.probeImageSize.mockResolvedValue({ width: 32, height: 32 }); + await act(async () => + posePopup()! + .querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )! + .click(), + ); + + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect( + useSpriteEditPreviewStore.getState().preview?.referenceNaturalSize, + ).toEqual({ source: null, width: 64, height: 32 }); + expect(useSpritePoseHandleStore.getState().session?.placement.rect).toEqual( + { x: 50, y: 0, width: 100, height: 150 }, + ); + }); + + it.each([ + { savedReference: null, keepKeyPose: false }, + { + savedReference: { source: null, width: 120, height: 90 }, + keepKeyPose: false, + }, + { savedReference: null, keepKeyPose: true }, + { + savedReference: { source: null, width: 120, height: 90 }, + keepKeyPose: true, + }, + ])( + '이미지 초안 삭제는 보류 기준 크기만 버린다: 저장값 $savedReference, 키 자세 유지 $keepKeyPose', + async ({ savedReference, keepKeyPose }) => { + let position = spritePosition({ + referenceNaturalSize: savedReference, + poses: keepKeyPose ? [makeSpritePose({ triggers: [KEY_ID_A] })] : [], + }); + seed(position); + render(position); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'discarded-pose.png', + }); + await act(async () => + posePopup()! + .querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )! + .click(), + ); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + + openPoseRowMenu(poseEditButtons().at(-1)!); + await flushPreview(); + await act(async () => menuItemByText('propertiesPanel.delete').click()); + + const generateDelete = mocks.patchPosition.mock.calls[0][4] as ( + current: CanonicalReactiveSpritePosition, + ) => Partial; + expect(generateDelete(position)).not.toHaveProperty( + 'referenceNaturalSize', + ); + position = { ...position, ...generateDelete(position) }; + expect(position.referenceNaturalSize).toEqual(savedReference); + expect(position.poses).toHaveLength(keepKeyPose ? 1 : 0); + act(() => seed(position)); + render(position); + mocks.patchPosition.mockClear(); + + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'new-pose.png', + }); + mocks.probeImageSize.mockResolvedValue({ width: 32, height: 32 }); + await act(async () => + posePopup()! + .querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )! + .click(), + ); + await act(async () => + triggerDropdownByText( + 'propertiesPanel.spriteTriggerPlaceholder', + ).click(), + ); + await act(async () => menuOptionByText(keepKeyPose ? 'S' : 'A').click()); + + const generateNewPose = mocks.patchPosition.mock.calls[0][4] as ( + current: CanonicalReactiveSpritePosition, + ) => Partial; + position = { ...position, ...generateNewPose(position) }; + expect(position.referenceNaturalSize).toEqual( + savedReference ?? { source: null, width: 32, height: 32 }, + ); + }, + ); + + it('이미지 초안 초기화 후 키만 지정해도 버린 기준 크기를 저장하지 않는다', async () => { + const position = spritePosition(); + seed(position); + render(position); + act(() => buttonByText('propertiesPanel.spriteAddPose').click()); + mocks.imageLoad.mockResolvedValue({ + success: true, + imagePath: 'discarded-pose.png', + }); + await act(async () => + posePopup()! + .querySelector( + 'button[aria-label="propertiesPanel.spriteImageSelect"]', + )! + .click(), + ); + await act(async () => + posePopup()! + .querySelector( + 'button[aria-label="imagePicker.reset"]', + )! + .click(), + ); + expect(mocks.patchPosition).not.toHaveBeenCalled(); + expect( + useSpriteEditPreviewStore.getState().preview?.referenceNaturalSize, + ).toBeUndefined(); + + await act(async () => + triggerDropdownByText('propertiesPanel.spriteTriggerPlaceholder').click(), + ); + await act(async () => menuOptionByText('A').click()); + + const generatePatch = mocks.patchPosition.mock.calls[0][4] as ( + current: CanonicalReactiveSpritePosition, + ) => Partial; + const patch = generatePatch(position); + expect(patch).not.toHaveProperty('referenceNaturalSize'); + expect(patch.poses).toEqual([ + expect.objectContaining({ + triggers: [KEY_ID_A], + imageOverride: null, + imageOverrideMetrics: null, + }), + ]); + }); + it('기준점 커밋 착지는 무효 draft를 같은 보정으로 rebase해 편집 중인 값을 지키지 않는다', async () => { const position = spritePosition({ poses: [ diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx index a27b62f6f..2f65b7eb9 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSpritePanel.tsx @@ -47,6 +47,7 @@ import { type ReactiveSpritePosition, type SpriteActivation, type SpriteAnchor, + type SpriteReferenceNaturalSize, type SpritePose, type SpriteTransform, } from '@src/types/key/sprites'; @@ -110,6 +111,25 @@ const isSameEditorTarget = ( current.positionId === next.positionId && current.poseId === next.poseId; +// 담당 키 지정 전 고른 이미지의 기준 크기는 최신 저장값이 없을 때만 채운다 +const initialPoseReferencePatch = ( + current: ReactiveSpritePosition, + reference: SpriteReferenceNaturalSize | null | undefined, +): Partial => + reference && + toRenderableImageRef(current.baseImage) === null && + !current.referenceNaturalSize + ? { referenceNaturalSize: reference } + : {}; + +const retainDraftReference = ( + poses: SpritePose[], + reference: SpriteReferenceNaturalSize | null | undefined, +): SpriteReferenceNaturalSize | null | undefined => + poses.some((pose) => toRenderableImageRef(pose.imageOverride) !== null) + ? reference + : undefined; + interface SingleSpritePanelProps { setPanelElement: (el: HTMLDivElement | null) => void; panelElement: HTMLDivElement | null; @@ -163,6 +183,7 @@ export const SingleSpritePanel: React.FC = ({ const [posesDraft, setPosesDraft] = useState<{ id: string; poses: SpritePose[]; + referenceNaturalSize?: SpriteReferenceNaturalSize | null; } | null>(null); const [pendingPoseWrites, setPendingPoseWrites] = useState< ReadonlySet @@ -232,7 +253,7 @@ export const SingleSpritePanel: React.FC = ({ if (resizeLanded) { if (posesDraft && posesDraft.id === canonicalPosition.id) { setPosesDraft({ - id: posesDraft.id, + ...posesDraft, poses: projectSpriteResize( { ...lastPositionSnapshot, poses: posesDraft.poses }, nextBounds, @@ -272,11 +293,18 @@ export const SingleSpritePanel: React.FC = ({ stableStringify(canonicalPosition.poses); if (pivotLanded) { const rebased = spritePivotChangePatch( - { ...lastPositionSnapshot, poses: posesDraft.poses }, + { + ...lastPositionSnapshot, + ...initialPoseReferencePatch( + lastPositionSnapshot, + posesDraft.referenceNaturalSize, + ), + poses: posesDraft.poses, + }, canonicalPosition.pivot, ); if (rebased) { - setPosesDraft({ id: posesDraft.id, poses: rebased.poses }); + setPosesDraft({ ...posesDraft, poses: rebased.poses }); draftRebased = true; } } @@ -295,7 +323,7 @@ export const SingleSpritePanel: React.FC = ({ ) { if (pendingPoseWrites.size > 0) { setPosesDraft({ - id: posesDraft.id, + ...posesDraft, poses: rebaseSpritePoseIntent( lastPositionSnapshot.poses, posesDraft.poses, @@ -333,6 +361,8 @@ export const SingleSpritePanel: React.FC = ({ posesDraft && posesDraft.id === position.id ? posesDraft.poses : position.poses; + const pendingReference = + posesDraft?.id === position.id ? posesDraft.referenceNaturalSize : null; const duplicatePose = findDuplicateTriggerPose(displayPoses); const hasEmptyTriggerPose = displayPoses.some( (pose) => pose.triggers.length === 0, @@ -367,6 +397,7 @@ export const SingleSpritePanel: React.FC = ({ positionId: activeEditorTarget.positionId, poseId: editingPose.poseId, fallbackPose: editingPose, + referenceNaturalSize: pendingReference, // 무효 draft는 canonical에 착지하지 못하므로 캔버스가 스냅샷을 우선한다 preferFallback: !posesCommittable || @@ -381,6 +412,7 @@ export const SingleSpritePanel: React.FC = ({ posesCommittable, posesDraft, position.id, + pendingReference, ]); // 언마운트 잔류 방지 - layout 시점에 회수해 한 프레임 잔상도 남기지 않는다 useLayoutEffect(() => () => useSpriteEditPreviewStore.getState().clear(), []); @@ -541,18 +573,20 @@ export const SingleSpritePanel: React.FC = ({ previewFields(patch); }; - // extra는 poses와 한 커밋으로 실어야 하는 스프라이트 필드(기준 크기 초기화 등) + // 새 이미지의 기준 크기는 자세와 한 커밋으로 저장 const updatePoses = ( rawPoses: SpritePose[], - extra: Partial = {}, - generateExtra?: ( - current: ReactiveSpritePosition, - ) => Partial | null, + initialReference?: SpriteReferenceNaturalSize, + imagePoseId?: string, ) => { // draft와 커밋이 같은 wire 정규형(트리거 정렬·dedup)을 공유해야 // canonical 착지 비교가 일치해 draft가 제때 풀린다 const nextPoses = toSpriteWireShape({ ...position, poses: rawPoses }).poses; - setPosesDraft({ id: position.id, poses: nextPoses }); + const referenceNaturalSize = retainDraftReference( + nextPoses, + pendingReference ?? initialReference, + ); + setPosesDraft({ id: position.id, poses: nextPoses, referenceNaturalSize }); const blocked = nextPoses.some((pose) => pose.triggers.length === 0) || findDuplicateTriggerPose(nextPoses) !== null; @@ -563,12 +597,24 @@ export const SingleSpritePanel: React.FC = ({ } const basePoses = canonicalPosition.poses; trackPoseWrite( - commitFields({ ...extra, poses: nextPoses }, (current) => { - const latestExtra = generateExtra ? generateExtra(current) : extra; - if (latestExtra === null) return null; + commitFields({ poses: nextPoses }, (current) => { + if ( + imagePoseId && + !current.poses.some((pose) => pose.poseId === imagePoseId) + ) { + return null; + } + const poses = rebaseSpritePoseIntent( + basePoses, + nextPoses, + current.poses, + ); return { - ...latestExtra, - poses: rebaseSpritePoseIntent(basePoses, nextPoses, current.poses), + ...initialPoseReferencePatch( + current, + retainDraftReference(poses, referenceNaturalSize), + ), + poses, }; }), ); @@ -592,7 +638,15 @@ export const SingleSpritePanel: React.FC = ({ pose.poseId === targetPoseId ? { ...pose, ...patch } : pose, ), }).poses; - setPosesDraft({ id: position.id, poses: nextPoses }); + const referenceNaturalSize = retainDraftReference( + nextPoses, + pendingReference, + ); + setPosesDraft({ + id: position.id, + poses: nextPoses, + referenceNaturalSize, + }); const blocked = nextPoses.some((pose) => pose.triggers.length === 0) || findDuplicateTriggerPose(nextPoses) !== null; @@ -609,7 +663,11 @@ export const SingleSpritePanel: React.FC = ({ nextPoses, current.poses, ); - if (!generatePatch) return { poses: rebasedPoses }; + const referencePatch = initialPoseReferencePatch( + current, + retainDraftReference(rebasedPoses, referenceNaturalSize), + ); + if (!generatePatch) return { ...referencePatch, poses: rebasedPoses }; const currentPose = current.poses.find( (pose) => pose.poseId === targetPoseId, ); @@ -617,6 +675,7 @@ export const SingleSpritePanel: React.FC = ({ const latestPatch = generatePatch(current, currentPose); if (!latestPatch) return null; return { + ...referencePatch, poses: rebasedPoses.map((pose) => pose.poseId === targetPoseId ? { ...pose, ...latestPatch } : pose, ), @@ -634,6 +693,7 @@ export const SingleSpritePanel: React.FC = ({ positionId: position.id, poseId: editingPose.poseId, fallbackPose: { ...editingPose, ...patch }, + referenceNaturalSize: pendingReference, preferFallback: true, }); }; @@ -662,7 +722,14 @@ export const SingleSpritePanel: React.FC = ({ const posePivotPatch = (next: SpriteAnchor | null) => { const base = editBasePose(); return base - ? spritePosePivotChangePatch(canonicalPosition, base, next) + ? spritePosePivotChangePatch( + { + ...canonicalPosition, + ...initialPoseReferencePatch(canonicalPosition, pendingReference), + }, + base, + next, + ) : null; }; @@ -713,6 +780,7 @@ export const SingleSpritePanel: React.FC = ({ positionId: position.id, poseId: editingPose.poseId, fallbackPose: editingPose, + referenceNaturalSize: pendingReference, preferFallback: true, }); } @@ -746,18 +814,22 @@ export const SingleSpritePanel: React.FC = ({ useLayoutEffect(() => { const store = useSpritePoseHandleStore.getState(); if (activeEditorTarget && editingPose) { + const effectivePosition = { + ...position, + ...initialPoseReferencePatch(position, pendingReference), + }; store.setSession({ - positionId: position.id, + positionId: effectivePosition.id, poseId: editingPose.poseId, - origin: { dx: position.dx, dy: position.dy }, - width: position.width, - height: position.height, - pivot: position.pivot, - imagePivot: editingPose.pivot ?? position.pivot, + origin: { dx: effectivePosition.dx, dy: effectivePosition.dy }, + width: effectivePosition.width, + height: effectivePosition.height, + pivot: effectivePosition.pivot, + imagePivot: editingPose.pivot ?? effectivePosition.pivot, followsBasePivot: editingPose.pivot == null, placement: placeSpriteVisual( - position, - spritePoseVisual(position, editingPose), + effectivePosition, + spritePoseVisual(effectivePosition, editingPose), ), transform: editingPose.transform, preview: (next) => handleCallbacksRef.current?.preview(next), @@ -773,7 +845,7 @@ export const SingleSpritePanel: React.FC = ({ } // 배치 기하가 위치의 여러 필드(상자·기준점·기준 크기·기본 이미지)에서 // 파생되므로 위치 전체를 의존성으로 둔다 - }, [activeEditorTarget, editingPose, position]); + }, [activeEditorTarget, editingPose, position, pendingReference]); // 언마운트 뒤에도 핸들은 드래그 시작 시점 세션을 붙들고 취소를 부른다. 마지막 // 렌더의 배선이 남아 있으면 무효 draft를 fallback preview로 다시 발행해 버린 자세가 // 캔버스에 남으므로, 게스처만 닫는 배선으로 바꾼 뒤 세션을 내린다 @@ -1057,18 +1129,6 @@ export const SingleSpritePanel: React.FC = ({ // 자세가 사라졌으면 폐기하고, 최신 poses에 다시 결합한다 const latest = latestPosesRef.current; if (!latest.some((pose) => pose.poseId === poseId)) return; - // 기본 이미지도 기준 크기도 없으면 첫 자세 이미지가 기준이 된다 (source 없음) - const referenceInit: Partial = - toRenderableImageRef(position.baseImage) === null && - !position.referenceNaturalSize - ? { - referenceNaturalSize: { - source: null, - width: picked.width, - height: picked.height, - }, - } - : {}; updatePoses( latest.map((pose) => pose.poseId === poseId @@ -1083,14 +1143,8 @@ export const SingleSpritePanel: React.FC = ({ } : pose, ), - referenceInit, - (current) => { - if (!current.poses.some((pose) => pose.poseId === poseId)) return null; - return toRenderableImageRef(current.baseImage) === null && - !current.referenceNaturalSize - ? referenceInit - : {}; - }, + { source: null, width: picked.width, height: picked.height }, + poseId, ); }; diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx index ab1e8653d..627332f71 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.render.test.tsx @@ -215,6 +215,36 @@ describe('SpriteItem 자세 편집 프리뷰', () => { useSpriteEditPreviewStore.setState({ preview: null }); }); + it('무효 상태의 기준 이미지 크기는 캔버스 배율에 반영되고 최신 기준 크기를 덮지 않는다', () => { + const previewPose = pose('pose-1', { + imageOverride: 'pose.png', + imageOverrideMetrics: { source: 'pose.png', width: 32, height: 32 }, + }); + act(() => + useSpriteEditPreviewStore.getState().publish({ + kind: 'pose', + positionId: SPRITE_ID, + poseId: previewPose.poseId, + fallbackPose: previewPose, + preferFallback: true, + referenceNaturalSize: { source: null, width: 64, height: 32 }, + }), + ); + const position = spritePosition({ width: 200, height: 150 }); + let host = renderSprite(position); + let img = host?.querySelector('img'); + expect(img?.style.width).toBe('100px'); + expect(img?.style.height).toBe('150px'); + + host = renderSprite({ + ...position, + referenceNaturalSize: { source: null, width: 32, height: 32 }, + }); + img = host?.querySelector('img'); + expect(img?.style.width).toBe('200px'); + expect(img?.style.height).toBe('150px'); + }); + it('발행된 자세는 composed poses의 최신 값이 스냅샷보다 우선한다', () => { act(() => useSpriteEditPreviewStore.getState().publish({ diff --git a/src/renderer/components/main/Grid/layers/SpriteItem.tsx b/src/renderer/components/main/Grid/layers/SpriteItem.tsx index 7d8d23a52..6797c5bc3 100644 --- a/src/renderer/components/main/Grid/layers/SpriteItem.tsx +++ b/src/renderer/components/main/Grid/layers/SpriteItem.tsx @@ -6,7 +6,10 @@ import { isErrorForCurrentSrc, useFailedImageSrcs, } from '@hooks/overlay/useFailedImageSrcs'; -import { resolveImageSource } from '@utils/core/imageSource'; +import { + resolveImageSource, + toRenderableImageRef, +} from '@utils/core/imageSource'; import { computeSpriteImageStyle } from '@utils/sprite/spriteImageStyles'; import SpriteImagePlaceholder from '@components/main/common/SpriteImagePlaceholder'; import { resolvePoseImage } from '@utils/sprite/poseResolver'; @@ -67,6 +70,15 @@ const SpriteItem = ({ : position.poses.find((pose) => pose.poseId === posePreview.poseId) ?? posePreview.fallbackPose : null; + const effectivePosition = + posePreview?.referenceNaturalSize && + toRenderableImageRef(position.baseImage) === null && + !position.referenceNaturalSize + ? { + ...position, + referenceNaturalSize: posePreview.referenceNaturalSize, + } + : position; // 유실 이미지를 그대로 두면 캔버스에 깨진 아이콘이 박히고, 송출 화면(오버레이)은 // 폴백을 그려 미리보기가 결과와 어긋난다. 폴백 규칙은 오버레이와 같다. @@ -83,13 +95,13 @@ const SpriteItem = ({ ); // 이미지·원본 크기는 한 벌로 움직인다 - 폴백도 기본 이미지 배치로 함께 간다 let visual = previewPose - ? spritePoseVisual(position, previewPose) + ? spritePoseVisual(effectivePosition, previewPose) : spriteIdleVisual(position); if (imageSrc && failedImageSrcs.has(imageSrc)) { imageSrc = fallbackSrc; visual = spriteIdleVisual(position); } - const placement = placeSpriteVisual(position, visual); + const placement = placeSpriteVisual(effectivePosition, visual); const transform = previewPose ? previewPose.transform : position.idleTransform; diff --git a/src/renderer/stores/grid/useSpriteEditPreviewStore.ts b/src/renderer/stores/grid/useSpriteEditPreviewStore.ts index f5a0d8528..3fd1d25df 100644 --- a/src/renderer/stores/grid/useSpriteEditPreviewStore.ts +++ b/src/renderer/stores/grid/useSpriteEditPreviewStore.ts @@ -1,5 +1,8 @@ import { create } from 'zustand'; -import type { SpritePose } from '@src/types/key/sprites'; +import type { + SpriteReferenceNaturalSize, + SpritePose, +} from '@src/types/key/sprites'; /** * 스프라이트 편집 캔버스 프리뷰 - 자세 팝업이 열려 있는 동안 캔버스가 그 자세를 @@ -13,6 +16,8 @@ export interface SpriteEditPreview { poseId: string; /** composed poses에 없거나(신규 draft) 커밋 불가 상태일 때 그릴 스냅샷 */ fallbackPose: SpritePose; + /** 담당 키 지정 전 고른 첫 상태 이미지의 기준 크기 */ + referenceNaturalSize?: SpriteReferenceNaturalSize | null; /** 무효 draft 편집 중 - composed의 옛 canonical 값 대신 스냅샷을 우선한다 */ preferFallback: boolean; } @@ -21,6 +26,7 @@ const previewEquals = (a: SpriteEditPreview, b: SpriteEditPreview): boolean => a.positionId === b.positionId && a.poseId === b.poseId && a.fallbackPose === b.fallbackPose && + a.referenceNaturalSize === b.referenceNaturalSize && a.preferFallback === b.preferFallback; interface SpriteEditPreviewStore { From 878c4e9b4713bd603b30377cf45b9ad68918f67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 16:21:30 +0900 Subject: [PATCH 107/125] =?UTF-8?q?feat:=20SIGTERM=20=EC=88=98=EC=8B=A0=20?= =?UTF-8?q?=EC=8B=9C=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=A0=95=EC=82=B0?= =?UTF-8?q?=EC=9D=84=20=EA=B1=B0=EC=B9=98=EB=8A=94=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/app.rs | 14 +++ src-tauri/src/state/app_state.rs | 126 ++++++++++++++++++++++++ src-tauri/src/state/mod.rs | 2 + src-tauri/src/state/unix_termination.rs | 15 +++ 6 files changed, 161 insertions(+) create mode 100644 src-tauri/src/state/unix_termination.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 23c8a829a..5bc733d0c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5143,6 +5143,7 @@ dependencies = [ "libc", "mio 1.1.1", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e8081e6cf..1ebbe1406 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,6 +57,9 @@ wuff = "0.2.8" [dev-dependencies] libc = "0.2" +[target."cfg(unix)".dependencies] +tokio = { version = "1", features = ["signal"] } + [target."cfg(windows)".dependencies] cpal = { version = "0.17.3", optional = true, default-features = false, features = ["asio"] } windows = { version = "0.61.3", features = [ diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index c59f11e5e..d7504a9f3 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -199,6 +199,20 @@ pub fn run() { } configure_main_window(app.handle()); + #[cfg(unix)] + { + let handle = app.handle().clone(); + state::unix_termination::install(move || { + if let Some(state) = handle.try_state::() { + state.request_frontend_shutdown(handle.clone()); + } + }) + .map_err(|error| { + log::error!("failed to install SIGTERM shutdown handler: {error}"); + error + })?; + } + #[cfg(target_os = "macos")] { state::macos_termination::install(app.handle()) diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index c303a873c..34f6bca0f 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -10109,6 +10109,132 @@ mod tests { .is_some_and(|active| active.completion.is_lifecycle())); } + #[cfg(unix)] + #[test] + fn sigterm_preserves_pending_state_and_allows_retry_after_canceled_flush() { + use std::{os::unix::process::ExitStatusExt, process::Command}; + + const CHILD_MODE: &str = "DMNOTE_SIGTERM_REGRESSION_MODE"; + const CHILD_STORE: &str = "DMNOTE_SIGTERM_REGRESSION_STORE"; + if let Ok(mode) = std::env::var(CHILD_MODE) { + let directory = std::env::var_os(CHILD_STORE).unwrap(); + let state = Arc::new( + AppState::initialize(AppStore::initialize_for_test(Path::new(&directory)).unwrap()) + .unwrap(), + ); + state.key_counter_enabled.store(true, Ordering::SeqCst); + let snapshot = state.store.snapshot(); + let key_mode = snapshot.selected_key_type; + let key = snapshot.keys[&key_mode][0].canonical(); + for expected in 1..=9 { + assert_eq!( + state.increment_key_counter_and_emit(&NoopCounterEmitter, &key_mode, &key), + Some(expected) + ); + } + state.store.flush().unwrap(); + state + .store + .update_deferred(|store| { + store.overlay_bounds = Some(StoredOverlayBounds { + x: 417.0, + y: 19.0, + width: 500.0, + height: 400.0, + native_position: None, + }); + }) + .unwrap(); + + let (sender, receiver) = mpsc::channel(); + if mode == "protected" { + let state = Arc::clone(&state); + let requests = AtomicUsize::new(0); + crate::state::unix_termination::install(move || { + let request_number = requests.fetch_add(1, Ordering::SeqCst) + 1; + let mut active = state.editor_flush_handshake.lock(); + let outcome = install_lifecycle_handshake( + &mut active, + EditorFlushHandshake { + id: format!("sigterm-{request_number}"), + completion: EditorFlushCompletion::Lifecycle( + FrontendLifecycleAction::Quit, + ), + target_windows: HashSet::from(["main".to_string()]), + pending_windows: HashSet::from(["main".to_string()]), + }, + ); + let installed = matches!(outcome, LifecycleHandshakeInstall::Installed); + sender + .send((active.as_ref().unwrap().id.clone(), installed)) + .unwrap(); + }) + .unwrap(); + } + + assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(3)).unwrap(), + ("sigterm-1".to_string(), true) + ); + assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(3)).unwrap(), + ("sigterm-1".to_string(), false) + ); + assert!(take_cancelable_editor_flush_handshake( + &mut state.editor_flush_handshake.lock(), + "sigterm-1" + ) + .is_some()); + assert_eq!(unsafe { libc::raise(libc::SIGTERM) }, 0); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(3)).unwrap(), + ("sigterm-3".to_string(), true) + ); + state.shutdown(); + return; + } + + for mode in ["default", "protected"] { + let directory = tempfile::tempdir().unwrap(); + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "state::app_state::tests::sigterm_preserves_pending_state_and_allows_retry_after_canceled_flush", + "--nocapture", + ]) + .env(CHILD_MODE, mode) + .env(CHILD_STORE, directory.path()) + .output() + .unwrap(); + if mode == "default" { + assert_eq!(output.status.signal(), Some(libc::SIGTERM)); + } else { + assert!( + output.status.success(), + "SIGTERM child failed: {:?}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + let persisted: AppStoreData = serde_json::from_slice( + &std::fs::read(directory.path().join("store.json")).unwrap(), + ) + .unwrap(); + let key = persisted.keys[&persisted.selected_key_type][0].canonical(); + let counter = persisted.key_counters[&persisted.selected_key_type][&key]; + let x = persisted.overlay_bounds.as_ref().map(|bounds| bounds.x); + eprintln!( + "SIGTERM {mode}: status={}, counter={counter}, bounds_x={x:?}", + output.status + ); + assert_eq!(counter, if mode == "protected" { 9 } else { 0 }); + assert_eq!(x, (mode == "protected").then_some(417.0)); + } + } + #[test] fn closing_history_can_time_out_and_reopen_the_gate() { let (sender, _receiver) = tokio::sync::oneshot::channel(); diff --git a/src-tauri/src/state/mod.rs b/src-tauri/src/state/mod.rs index 7ab455118..888292f57 100644 --- a/src-tauri/src/state/mod.rs +++ b/src-tauri/src/state/mod.rs @@ -20,6 +20,8 @@ pub(crate) mod native_element_id; pub(crate) mod panel_drag; pub(crate) mod plugin; pub mod store; +#[cfg(unix)] +pub(crate) mod unix_termination; #[cfg(target_os = "windows")] pub(crate) mod windows_window_corners; diff --git a/src-tauri/src/state/unix_termination.rs b/src-tauri/src/state/unix_termination.rs new file mode 100644 index 000000000..6d65b2206 --- /dev/null +++ b/src-tauri/src/state/unix_termination.rs @@ -0,0 +1,15 @@ +use std::io; + +use tokio::signal::unix::{signal, SignalKind}; + +pub(crate) fn install(request_shutdown: impl Fn() + Send + 'static) -> io::Result<()> { + let mut termination = + tauri::async_runtime::block_on(async { signal(SignalKind::terminate()) })?; + tauri::async_runtime::spawn(async move { + while termination.recv().await.is_some() { + log::info!("[Shutdown] SIGTERM received; requesting frontend editor flush"); + request_shutdown(); + } + }); + Ok(()) +} From 44ad8f6d7871470fdb91b6b1a86751a4108ccca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 16:22:08 +0900 Subject: [PATCH 108/125] =?UTF-8?q?fix:=20=EC=98=81=EC=86=8D=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20API=EB=A5=BC=20=EC=A2=85=EB=A3=8C=20barrier=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81=EC=97=90=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/api/modules/appApi.test.ts | 7 +- src/renderer/api/modules/appApi.ts | 26 +-- src/renderer/api/modules/cssApi.ts | 41 +++-- src/renderer/api/modules/invokeEditorWrite.ts | 7 + src/renderer/api/modules/jsApi.ts | 19 +- src/renderer/api/modules/keyModeApi.ts | 4 +- src/renderer/api/modules/keysApi.ts | 13 +- src/renderer/api/modules/noteTabApi.ts | 14 +- src/renderer/api/modules/obsApi.ts | 5 +- src/renderer/api/modules/overlayApi.ts | 13 +- .../persistentWrites.lifecycle.test.ts | 170 ++++++++++++++++++ src/renderer/api/modules/pluginApi.ts | 10 +- src/renderer/api/modules/presetsApi.ts | 5 +- src/renderer/api/modules/resourceApi.ts | 83 +++++---- src/renderer/api/modules/settingsApi.ts | 3 +- .../editor/runtime/editorWriteBarrier.ts | 2 +- 16 files changed, 320 insertions(+), 102 deletions(-) create mode 100644 src/renderer/api/modules/invokeEditorWrite.ts create mode 100644 src/renderer/api/modules/persistentWrites.lifecycle.test.ts diff --git a/src/renderer/api/modules/appApi.test.ts b/src/renderer/api/modules/appApi.test.ts index 85aa70a2d..9ba5164a5 100644 --- a/src/renderer/api/modules/appApi.test.ts +++ b/src/renderer/api/modules/appApi.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { invoke, coordinatorFlush, drainEditorWrites } = vi.hoisted(() => ({ invoke: vi.fn(), @@ -11,7 +11,7 @@ vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ editorCoordinator: { flush: coordinatorFlush }, })); vi.mock('@src/renderer/editor/runtime/editorWriteBarrier', () => ({ - drainEditorWrites, + beginEditorWriteBarrier: () => drainEditorWrites, })); import { @@ -38,6 +38,9 @@ const bootstrapPayload = () => ({ }); describe('runAfterEditorFlush', () => { + beforeEach(() => { + drainEditorWrites.mockResolvedValue(true); + }); afterEach(() => { vi.restoreAllMocks(); invoke.mockReset(); diff --git a/src/renderer/api/modules/appApi.ts b/src/renderer/api/modules/appApi.ts index b952b97d2..87e612c9f 100644 --- a/src/renderer/api/modules/appApi.ts +++ b/src/renderer/api/modules/appApi.ts @@ -7,6 +7,7 @@ import type { } from '@src/types/plugin/api'; import { subscribe } from './shared'; import { assertCanonicalEditorDocument } from '@src/types/editor'; +import { beginEditorWriteBarrier } from '@src/renderer/editor/runtime/editorWriteBarrier'; import type { BootstrapPayload, @@ -30,8 +31,17 @@ export async function runAfterEditorFlush( load: EditorCoordinatorLoader = loadEditorCoordinator, ): Promise { try { - const { editorCoordinator } = await load(); - await editorCoordinator.flush(); + const drainWrites = beginEditorWriteBarrier(); + let writesCommitted: boolean; + try { + const { editorCoordinator } = await load(); + await editorCoordinator.flush(); + } finally { + writesCommitted = await drainWrites(); + } + if (!writesCommitted) { + throw new Error('pending window writes failed to drain'); + } } catch (error) { console.error(`[Editor] ${action} canceled because saving failed`, error); throw error; @@ -40,15 +50,9 @@ export async function runAfterEditorFlush( } export const acknowledgeLifecycleAfterEditorFlush = (handshakeId: string) => - runAfterEditorFlush('app lifecycle', async () => { - const editorBarrier = await import( - '@src/renderer/editor/runtime/editorWriteBarrier' - ); - if (!(await editorBarrier.drainEditorWrites())) { - throw new Error('pending window writes failed to drain'); - } - return invoke('app_quit_after_editor_flush', { handshakeId }); - }); + runAfterEditorFlush('app lifecycle', () => + invoke('app_quit_after_editor_flush', { handshakeId }), + ); export const cancelLifecycleEditorFlush = (handshakeId: string) => invoke('app_cancel_editor_flush', { handshakeId }); diff --git a/src/renderer/api/modules/cssApi.ts b/src/renderer/api/modules/cssApi.ts index 63151fc11..381af5508 100644 --- a/src/renderer/api/modules/cssApi.ts +++ b/src/renderer/api/modules/cssApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; @@ -14,16 +15,16 @@ export const cssApi = { get: () => invoke('css_get'), getUse: () => invoke('css_get_use'), toggle: (enabled: boolean) => - invoke('css_toggle', { enabled }), - load: () => invoke('css_load'), + invokeEditorWrite('css_toggle', { enabled }), + load: () => invokeEditorWrite('css_load'), setContent: (content: string) => - invoke('css_set_content', { content }), - reset: () => invoke('css_reset'), + invokeEditorWrite('css_set_content', { content }), + reset: () => invokeEditorWrite('css_reset'), historyGet: () => invoke('css_history_get'), historyActivate: (path: string) => - invoke('css_history_activate', { path }), + invokeEditorWrite('css_history_activate', { path }), historyRemove: (path: string) => - invoke('css_history_remove', { path }), + invokeEditorWrite('css_history_remove', { path }), onUse: (listener: (payload: CssTogglePayload) => void) => subscribe('css:use', listener), onContent: (listener: (payload: CustomCss) => void) => @@ -38,18 +39,21 @@ export const cssApi = { tabId, }), load: (tabId: string) => - invoke('css_tab_load', { - tabId, - }), + invokeEditorWrite( + 'css_tab_load', + { + tabId, + }, + ), clear: (tabId: string) => - invoke( + invokeEditorWrite( 'css_tab_clear', { tabId, }, ), toggle: (tabId: string, enabled: boolean) => - invoke( + invokeEditorWrite( 'css_tab_toggle', { tabId, @@ -57,7 +61,7 @@ export const cssApi = { }, ), activateHistory: (tabId: string, path: string) => - invoke( + invokeEditorWrite( 'css_tab_activate_history', { tabId, @@ -65,17 +69,20 @@ export const cssApi = { }, ), export: (tabId: string) => - invoke( + invokeEditorWrite( 'css_tab_export', { tabId, }, ), set: (tabId: string, css: import('@src/types/plugin/css').TabCss | null) => - invoke('css_tab_set', { - tabId, - css, - }), + invokeEditorWrite( + 'css_tab_set', + { + tabId, + css, + }, + ), onChanged: ( listener: ( payload: import('@src/types/plugin/api').TabCssResponse, diff --git a/src/renderer/api/modules/invokeEditorWrite.ts b/src/renderer/api/modules/invokeEditorWrite.ts new file mode 100644 index 000000000..a9a228ce0 --- /dev/null +++ b/src/renderer/api/modules/invokeEditorWrite.ts @@ -0,0 +1,7 @@ +import { invoke } from '@tauri-apps/api/core'; +import { trackEditorWrite } from '@src/renderer/editor/runtime/editorWriteBarrier'; + +// 영속 변경 명령 전용, 프론트 정산을 기다리는 종료·history 요청에는 사용하지 않음 +export const invokeEditorWrite = ( + ...args: Parameters +): Promise => trackEditorWrite(invoke(...args)); diff --git a/src/renderer/api/modules/jsApi.ts b/src/renderer/api/modules/jsApi.ts index f3890a90b..10884e47d 100644 --- a/src/renderer/api/modules/jsApi.ts +++ b/src/renderer/api/modules/jsApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; @@ -15,15 +16,19 @@ export const jsApi = { get: () => invoke('js_get'), getUse: () => invoke('js_get_use'), toggle: (enabled: boolean) => - invoke('js_toggle', { enabled }), - load: () => invoke('js_load'), - reload: () => invoke('js_reload'), - remove: (id: string) => invoke('js_remove_plugin', { id }), + invokeEditorWrite('js_toggle', { enabled }), + load: () => invokeEditorWrite('js_load'), + reload: () => invokeEditorWrite('js_reload'), + remove: (id: string) => + invokeEditorWrite('js_remove_plugin', { id }), setPluginEnabled: (id: string, enabled: boolean) => - invoke('js_set_plugin_enabled', { id, enabled }), + invokeEditorWrite('js_set_plugin_enabled', { + id, + enabled, + }), setContent: (content: string) => - invoke('js_set_content', { content }), - reset: () => invoke('js_reset'), + invokeEditorWrite('js_set_content', { content }), + reset: () => invokeEditorWrite('js_reset'), onUse: (listener: (payload: JsTogglePayload) => void) => subscribe('js:use', listener), onState: (listener: (payload: JsStatePayload) => void) => diff --git a/src/renderer/api/modules/keyModeApi.ts b/src/renderer/api/modules/keyModeApi.ts index b400557dc..6b855c00e 100644 --- a/src/renderer/api/modules/keyModeApi.ts +++ b/src/renderer/api/modules/keyModeApi.ts @@ -1,6 +1,6 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invokeEditorWrite } from './invokeEditorWrite'; import type { KeysModeResponse } from '@src/types/plugin/api'; export const setKeyMode = (mode: string) => - invoke('keys_set_mode', { mode }); + invokeEditorWrite('keys_set_mode', { mode }); diff --git a/src/renderer/api/modules/keysApi.ts b/src/renderer/api/modules/keysApi.ts index 9eaabc26b..f4a66f084 100644 --- a/src/renderer/api/modules/keysApi.ts +++ b/src/renderer/api/modules/keysApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; import { rawKeyEventBus } from '@utils/core/rawKeyEventBus'; @@ -107,12 +108,12 @@ export const keysApi = { invoke('keys_reset_mode', { mode }), ), setCounters: (counters: KeyCounters) => - invoke('keys_set_counters', { counters }), - resetCounters: () => invoke('keys_reset_counters'), + invokeEditorWrite('keys_set_counters', { counters }), + resetCounters: () => invokeEditorWrite('keys_reset_counters'), resetCountersMode: (mode: string) => - invoke('keys_reset_counters_mode', { mode }), + invokeEditorWrite('keys_reset_counters_mode', { mode }), resetSingleCounter: (mode: string, key: string) => - invoke('keys_reset_single_counter', { mode, key }), + invokeEditorWrite('keys_reset_single_counter', { mode, key }), onChanged: (listener: (keys: KeyMappings) => void) => subscribe('keys:changed', listener), onPositionsChanged: (listener: (positions: KeyPositions) => void) => @@ -165,9 +166,9 @@ export const keysApi = { invoke('custom_tabs_delete', { id }), ), select: (id: string) => - invoke('custom_tabs_select', { id }), + invokeEditorWrite('custom_tabs_select', { id }), restore: (customTabs: CustomTab[], selectedKeyType: string) => - invoke('custom_tabs_restore', { + invokeEditorWrite('custom_tabs_restore', { customTabs, selectedKeyType, }), diff --git a/src/renderer/api/modules/noteTabApi.ts b/src/renderer/api/modules/noteTabApi.ts index 8db850043..27ebcaf38 100644 --- a/src/renderer/api/modules/noteTabApi.ts +++ b/src/renderer/api/modules/noteTabApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; @@ -14,12 +15,15 @@ export const noteTabApi = { tabId: string, settings: import('@src/types/settings/noteSettings').TabNoteSettings | null, ) => - invoke('note_tab_set', { - tabId, - settings, - }), + invokeEditorWrite( + 'note_tab_set', + { + tabId, + settings, + }, + ), clear: (tabId: string) => - invoke( + invokeEditorWrite( 'note_tab_clear', { tabId, diff --git a/src/renderer/api/modules/obsApi.ts b/src/renderer/api/modules/obsApi.ts index bd6d26122..c79b4e14e 100644 --- a/src/renderer/api/modules/obsApi.ts +++ b/src/renderer/api/modules/obsApi.ts @@ -1,13 +1,14 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; import type { ObsStatus } from '@src/types/obs'; export const obsApi = { - start: () => invoke('obs_start'), + start: () => invokeEditorWrite('obs_start'), stop: () => invoke('obs_stop'), status: () => invoke('obs_status'), - regenerateToken: () => invoke('obs_regenerate_token'), + regenerateToken: () => invokeEditorWrite('obs_regenerate_token'), onStatus: (listener: (status: ObsStatus) => void) => subscribe('obs:status', listener), // OBS WS 재연결/lag 복구 신호 (ipcShim 로컬 합성 이벤트 — 네이티브에서는 발화하지 않음) diff --git a/src/renderer/api/modules/overlayApi.ts b/src/renderer/api/modules/overlayApi.ts index 96fdf128e..e095f92bd 100644 --- a/src/renderer/api/modules/overlayApi.ts +++ b/src/renderer/api/modules/overlayApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; @@ -13,10 +14,11 @@ import type { export const overlayApi = { get: () => invoke('overlay_get'), setVisible: (visible: boolean) => - invoke('overlay_set_visible', { visible }), - setLock: (locked: boolean) => invoke('overlay_set_lock', { locked }), + invokeEditorWrite('overlay_set_visible', { visible }), + setLock: (locked: boolean) => + invokeEditorWrite('overlay_set_lock', { locked }), setAnchor: (anchor: string) => - invoke('overlay_set_anchor', { anchor }), + invokeEditorWrite('overlay_set_anchor', { anchor }), // 반환값은 실제로 적용된 bounds - 요청 크기가 한계를 넘으면 잘린다 // OBS 모드는 allowlist 밖이라 shim이 거절한다 resize: (payload: { @@ -27,9 +29,10 @@ export const overlayApi = { contentLeftOffset?: number; fixedPositionDeltaX?: number; fixedPositionDeltaY?: number; - }) => invoke('overlay_resize', { payload }), + }) => invokeEditorWrite('overlay_resize', { payload }), // 오버레이를 작업 영역 안쪽 중앙으로 되돌린다 - 화면 밖으로 나갔을 때의 탈출구 - resetPosition: () => invoke('overlay_reset_position'), + resetPosition: () => + invokeEditorWrite('overlay_reset_position'), // 반환값은 페이드 적용 여부 (창 없음·미지원 플랫폼이면 false) transitionFade: (alpha: number, durationMs: number) => invoke('overlay_transition_fade', { alpha, durationMs }), diff --git a/src/renderer/api/modules/persistentWrites.lifecycle.test.ts b/src/renderer/api/modules/persistentWrites.lifecycle.test.ts new file mode 100644 index 000000000..3f902fbc5 --- /dev/null +++ b/src/renderer/api/modules/persistentWrites.lifecycle.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beginEditorWriteBarrier } from '@src/renderer/editor/runtime/editorWriteBarrier'; +import { overlayApi } from './overlayApi'; +import { cssApi } from './cssApi'; +import { jsApi } from './jsApi'; +import { pluginApi } from './pluginApi'; +import { noteTabApi } from './noteTabApi'; +import { keysApi } from './keysApi'; +import { setKeyMode } from './keyModeApi'; +import { + counterAnimationApi, + keySoundOutputApi, + soundApi, +} from './resourceApi'; +import { obsApi } from './obsApi'; +import { historyApi } from './historyApi'; +import { acknowledgeLifecycleAfterEditorFlush } from './appApi'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('./shared', () => ({ subscribe: vi.fn() })); +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { flush: vi.fn(async () => {}) }, +})); + +const writes: [string, () => Promise][] = [ + ['overlay_set_visible', () => overlayApi.setVisible(true)], + ['overlay_set_lock', () => overlayApi.setLock(true)], + ['overlay_set_anchor', () => overlayApi.setAnchor('top-left')], + ['overlay_resize', () => overlayApi.resize({ width: 400, height: 300 })], + ['overlay_reset_position', () => overlayApi.resetPosition()], + ['css_toggle', () => cssApi.toggle(true)], + ['css_set_content', () => cssApi.setContent('body {}')], + ['css_reset', () => cssApi.reset()], + ['css_history_activate', () => cssApi.historyActivate('saved.css')], + ['css_history_remove', () => cssApi.historyRemove('saved.css')], + ['css_tab_clear', () => cssApi.tab.clear('4key')], + ['css_tab_toggle', () => cssApi.tab.toggle('4key', true)], + [ + 'css_tab_activate_history', + () => cssApi.tab.activateHistory('4key', 'saved.css'), + ], + ['css_tab_set', () => cssApi.tab.set('4key', null)], + ['js_toggle', () => jsApi.toggle(true)], + ['js_reload', () => jsApi.reload()], + ['js_remove_plugin', () => jsApi.remove('test-plugin')], + ['js_set_plugin_enabled', () => jsApi.setPluginEnabled('test-plugin', true)], + ['js_set_content', () => jsApi.setContent('')], + ['js_reset', () => jsApi.reset()], + ['plugin_storage_set', () => pluginApi.storage.set('test/key', { value: 1 })], + ['plugin_storage_remove', () => pluginApi.storage.remove('test/key')], + ['plugin_storage_clear', () => pluginApi.storage.clear()], + [ + 'plugin_storage_clear_by_prefix', + () => pluginApi.storage.clearByPrefix('test/'), + ], + ['note_tab_set', () => noteTabApi.set('4key', null)], + ['note_tab_clear', () => noteTabApi.clear('4key')], + ['keys_set_mode', () => setKeyMode('4key')], + ['keys_set_counters', () => keysApi.setCounters({})], + ['keys_reset_counters', () => keysApi.resetCounters()], + ['keys_reset_counters_mode', () => keysApi.resetCountersMode('4key')], + ['keys_reset_single_counter', () => keysApi.resetSingleCounter('4key', 'A')], + ['custom_tabs_select', () => keysApi.customTabs.select('tab-id')], + ['custom_tabs_restore', () => keysApi.customTabs.restore([], '4key')], + ['sound_rename', () => soundApi.rename('sound.wav', 'Renamed')], + ['sound_set_hidden', () => soundApi.setHidden('sound.wav', true)], + ['sound_set_enabled', () => soundApi.setEnabled('sound.wav', false)], + [ + 'sound_save_processed_wav', + () => soundApi.saveProcessedWav('wav-data', 'trimmed.wav'), + ], + [ + 'sound_update_processed_wav', + () => soundApi.updateProcessedWav('sound.wav', 'wav-data'), + ], + [ + 'key_sound_set_output_backend', + () => keySoundOutputApi.setBackend({ kind: 'defaultDevice' }), + ], + [ + 'counter_animation_create', + () => + counterAnimationApi.create({ + name: 'Saved', + bezier: [0, 0, 1, 1], + scale: 1, + durationMs: 100, + }), + ], + ['obs_start', () => obsApi.start()], + ['obs_regenerate_token', () => obsApi.regenerateToken()], +]; + +const deferred = () => { + let resolve!: (value: unknown) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; + +describe('영속 쓰기 API의 종료 정산', () => { + beforeEach(() => { + mocks.invoke.mockReset(); + }); + + it.each(writes)( + '%s 응답 전 정산을 끝내지 않고 실패를 호출자와 barrier에 전달한다', + async (command, write) => { + const pending = deferred(); + mocks.invoke.mockReturnValue(pending.promise); + const drain = beginEditorWriteBarrier(); + const saving = write(); + const rejected = saving.catch((error: unknown) => error); + let settled = false; + const draining = drain().then((result) => { + settled = true; + return result; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const settledBeforeResponse = settled; + const error = new Error('disk write rejected'); + pending.reject(error); + expect(await rejected).toBe(error); + expect(await draining).toBe(false); + expect(settledBeforeResponse).toBe(false); + expect(mocks.invoke.mock.calls.map(([name]) => name)).toEqual([command]); + }, + ); + + it('성공한 쓰기의 반환값을 유지하고 종료 확인 응답보다 먼저 끝낸다', async () => { + const pending = deferred(); + mocks.invoke.mockImplementation((command) => + command === 'plugin_storage_set' ? pending.promise : Promise.resolve(), + ); + const saved = pluginApi.storage.set('plugin/key', 1); + const ack = acknowledgeLifecycleAfterEditorFlush('write-test'); + await new Promise((resolve) => setTimeout(resolve, 0)); + const commandsBeforeSaved = mocks.invoke.mock.calls.map(([name]) => name); + pending.resolve(undefined); + await expect(saved).resolves.toBeUndefined(); + await ack; + expect(commandsBeforeSaved).toEqual(['plugin_storage_set']); + expect(mocks.invoke.mock.calls.map(([name]) => name)).toEqual([ + 'plugin_storage_set', + 'app_quit_after_editor_flush', + ]); + }); + + it.each([ + ['settings read', () => overlayApi.get()], + ['plugin read', () => pluginApi.storage.get('test/key')], + ['history request', () => historyApi.undo('undo-id')], + ] as const)('%s는 저장 정산이 기다리지 않는다', async (_name, operation) => { + const pending = deferred(); + mocks.invoke.mockReturnValue(pending.promise); + const drain = beginEditorWriteBarrier(); + const response = operation(); + const result = await Promise.race([ + drain(), + new Promise((resolve) => setTimeout(() => resolve('blocked'), 50)), + ]); + pending.resolve(undefined); + await response; + expect(result).toBe(true); + }); +}); diff --git a/src/renderer/api/modules/pluginApi.ts b/src/renderer/api/modules/pluginApi.ts index d8cc7befb..a73dd06ae 100644 --- a/src/renderer/api/modules/pluginApi.ts +++ b/src/renderer/api/modules/pluginApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; export const pluginApi = { @@ -6,11 +7,12 @@ export const pluginApi = { invoke('plugin_storage_get', { key }), set: (key: string, value: unknown) => - invoke('plugin_storage_set', { key, value }), + invokeEditorWrite('plugin_storage_set', { key, value }), - remove: (key: string) => invoke('plugin_storage_remove', { key }), + remove: (key: string) => + invokeEditorWrite('plugin_storage_remove', { key }), - clear: () => invoke('plugin_storage_clear'), + clear: () => invokeEditorWrite('plugin_storage_clear'), keys: () => invoke('plugin_storage_keys'), @@ -18,7 +20,7 @@ export const pluginApi = { invoke('plugin_storage_has_data', { prefix }), clearByPrefix: (prefix: string) => - invoke('plugin_storage_clear_by_prefix', { prefix }), + invokeEditorWrite('plugin_storage_clear_by_prefix', { prefix }), }, registerCleanup: () => { console.warn( diff --git a/src/renderer/api/modules/presetsApi.ts b/src/renderer/api/modules/presetsApi.ts index 5c14c4731..d9ce3f805 100644 --- a/src/renderer/api/modules/presetsApi.ts +++ b/src/renderer/api/modules/presetsApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; import { runExclusiveLegacyMutation } from '@src/renderer/editor/runtime/legacyEditorMutation'; @@ -8,12 +9,12 @@ import type { } from '@src/types/plugin/api'; export const presetsApi = { - save: () => invoke('preset_save'), + save: () => invokeEditorWrite('preset_save'), load: () => runExclusiveLegacyMutation(() => invoke('preset_load'), ), - saveTab: () => invoke('preset_save_tab'), + saveTab: () => invokeEditorWrite('preset_save_tab'), loadTab: () => runExclusiveLegacyMutation(() => invoke('preset_load_tab'), diff --git a/src/renderer/api/modules/resourceApi.ts b/src/renderer/api/modules/resourceApi.ts index b23fc8f8d..4890acf98 100644 --- a/src/renderer/api/modules/resourceApi.ts +++ b/src/renderer/api/modules/resourceApi.ts @@ -1,3 +1,4 @@ +import { invokeEditorWrite } from './invokeEditorWrite'; import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; import { @@ -7,26 +8,35 @@ import { export const fontApi = { load: () => - invoke('font_load'), + invokeEditorWrite( + 'font_load', + ), }; export const imageApi = { load: () => runLegacyEditorMutation(() => - invoke('image_load'), + invokeEditorWrite( + 'image_load', + ), ), }; export const soundApi = { load: () => - invoke('sound_load'), + invokeEditorWrite( + 'sound_load', + ), list: () => invoke('sound_list'), rename: (soundPath: string, displayName: string) => - invoke('sound_rename', { - soundPath, - displayName, - }), + invokeEditorWrite( + 'sound_rename', + { + soundPath, + displayName, + }, + ), remove: (soundPath: string) => runExclusiveLegacyMutation(() => invoke( @@ -37,13 +47,13 @@ export const soundApi = { ), ), setHidden: (soundPath: string, hidden: boolean) => - invoke( + invokeEditorWrite( 'sound_set_hidden', { soundPath, hidden }, ), // deprecated — setHidden의 역논리 별칭 (enabled = !hidden) setEnabled: (soundPath: string, enabled: boolean) => - invoke( + invokeEditorWrite( 'sound_set_enabled', { soundPath, enabled }, ), @@ -55,19 +65,18 @@ export const soundApi = { trimStartRatio?: number, trimEndRatio?: number, ) => - invoke( - 'sound_save_processed_wav', - { - request: { - wavBase64, - fileName, - originalBase64, - originalExtension, - trimStartRatio, - trimEndRatio, - }, + invokeEditorWrite< + import('@src/types/plugin/api').SoundSaveProcessedWavResult + >('sound_save_processed_wav', { + request: { + wavBase64, + fileName, + originalBase64, + originalExtension, + trimStartRatio, + trimEndRatio, }, - ), + }), loadOriginal: (soundPath: string) => invoke( 'sound_load_original', @@ -80,18 +89,17 @@ export const soundApi = { trimEndRatio?: number, displayName?: string, ) => - invoke( - 'sound_update_processed_wav', - { - request: { - soundPath, - wavBase64, - trimStartRatio, - trimEndRatio, - displayName, - }, + invokeEditorWrite< + import('@src/types/plugin/api').SoundUpdateProcessedWavResult + >('sound_update_processed_wav', { + request: { + soundPath, + wavBase64, + trimStartRatio, + trimEndRatio, + displayName, }, - ), + }), setLatencyLogging: (enabled: boolean) => invoke('key_sound_set_latency_logging', { enabled }).then(() => undefined), }; @@ -137,7 +145,9 @@ export const keySoundOutputApi = { invoke('key_sound_list_output_devices'), getState: () => invoke('key_sound_get_output_state'), setBackend: (backend: KeySoundOutputBackend) => - invoke('key_sound_set_output_backend', { backend }), + invokeEditorWrite('key_sound_set_output_backend', { + backend, + }), }; export const counterAnimationApi = { @@ -148,10 +158,9 @@ export const counterAnimationApi = { create: ( request: import('@src/types/plugin/api').CounterAnimationCreateRequest, ) => - invoke( - 'counter_animation_create', - { request }, - ), + invokeEditorWrite< + import('@src/types/plugin/api').CounterAnimationUpsertResponse + >('counter_animation_create', { request }), update: ( request: import('@src/types/plugin/api').CounterAnimationUpdateRequest, options: { preflight?: () => void } = {}, diff --git a/src/renderer/api/modules/settingsApi.ts b/src/renderer/api/modules/settingsApi.ts index 4903b9211..ca6629019 100644 --- a/src/renderer/api/modules/settingsApi.ts +++ b/src/renderer/api/modules/settingsApi.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { subscribe } from './shared'; +import { trackEditorWrite } from '@src/renderer/editor/runtime/editorWriteBarrier'; import type { SettingsState, @@ -10,7 +11,7 @@ import type { export const settingsApi = { get: () => invoke('settings_get'), update: (patch: SettingsPatchInput) => - invoke('settings_update', { patch }), + trackEditorWrite(invoke('settings_update', { patch })), onChanged: (listener: (diff: SettingsDiff) => void) => subscribe('settings:changed', listener), }; diff --git a/src/renderer/editor/runtime/editorWriteBarrier.ts b/src/renderer/editor/runtime/editorWriteBarrier.ts index c18ba9572..1b3ad67cf 100644 --- a/src/renderer/editor/runtime/editorWriteBarrier.ts +++ b/src/renderer/editor/runtime/editorWriteBarrier.ts @@ -22,7 +22,7 @@ export const drainEditorWrites = async (): Promise => { }; export const beginEditorWriteBarrier = () => { - const writes = new Set>(); + const writes = new Set(pendingWrites); activeBarriers.add(writes); return async (): Promise => { From 76f13309dc8da26e51fb0a0544c6e799a84921fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 16:22:22 +0900 Subject: [PATCH 109/125] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EC=9D=84=20barrier=20=EC=95=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=ED=95=98=EA=B3=A0=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EC=A0=95=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/settingsApi.lifecycle.test.tsx | 162 +++++++++++++++++ .../focusedEditorSettlement.deferred.test.tsx | 168 ++++++++++++++++++ .../editor/runtime/focusedEditorSettlement.ts | 15 +- .../hooks/Grid/useGridKeyboard.test.tsx | 162 +++++++++++++++++ src/renderer/hooks/Grid/useGridKeyboard.ts | 6 + .../hooks/panel/usePanelCloseRequest.test.tsx | 3 - .../hooks/pendingOptimisticCommits.ts | 17 +- .../useOptimisticAsyncBooleanCommit.test.tsx | 17 ++ .../hooks/useOptimisticAsyncBooleanCommit.ts | 33 ++-- src/renderer/stores/grid/usePanelHostStore.ts | 2 - 10 files changed, 557 insertions(+), 28 deletions(-) create mode 100644 src/renderer/api/modules/settingsApi.lifecycle.test.tsx create mode 100644 src/renderer/editor/runtime/focusedEditorSettlement.deferred.test.tsx diff --git a/src/renderer/api/modules/settingsApi.lifecycle.test.tsx b/src/renderer/api/modules/settingsApi.lifecycle.test.tsx new file mode 100644 index 000000000..fd433c105 --- /dev/null +++ b/src/renderer/api/modules/settingsApi.lifecycle.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Checkbox from '@components/main/common/Checkbox'; +import { flushFocusedEditor } from '@src/renderer/editor/runtime/lifecycleEditorFlush'; +import { settingsApi } from './settingsApi'; +import { acknowledgeLifecycleAfterEditorFlush } from './appApi'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn(), flush: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { flush: mocks.flush }, +})); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; + +const nextTurn = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('설정 저장과 lifecycle 응답', () => { + let root: Root; + let iframe: HTMLIFrameElement; + let host: HTMLDivElement; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + mocks.invoke.mockReset().mockResolvedValue(undefined); + mocks.flush.mockReset().mockResolvedValue(undefined); + vi.spyOn(console, 'error').mockImplementation(() => {}); + iframe = document.createElement('iframe'); + document.body.append(iframe); + const child = iframe.contentWindow!; + child.requestAnimationFrame = () => 1; + child.cancelAnimationFrame = () => {}; + host = child.document.createElement('div'); + child.document.body.append(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + iframe.remove(); + vi.restoreAllMocks(); + }); + + const arrangeWrite = () => { + const write = deferred(); + mocks.invoke.mockImplementation((command) => + command === 'settings_update' ? write.promise : Promise.resolve(), + ); + return write; + }; + + it.each(['success', 'failure'] as const)( + '프레임이 멈춘 토글의 저장 %s를 정산한 뒤에만 ack를 결정한다', + async (outcome) => { + const write = arrangeWrite(); + act(() => + root.render( + { + void settingsApi.update({ noteEffect: true }).catch(() => {}); + }} + />, + ), + ); + act(() => host.querySelector('[role="switch"]')!.click()); + expect(mocks.invoke).not.toHaveBeenCalled(); + let completed = false; + let settlement!: Promise; + await act(async () => { + settlement = (async () => { + const committed = await flushFocusedEditor(); + if (committed) + await acknowledgeLifecycleAfterEditorFlush('close-test'); + completed = true; + return committed; + })(); + await nextTurn(); + await nextTurn(); + }); + expect(mocks.invoke).toHaveBeenCalledWith('settings_update', { + patch: { noteEffect: true }, + }); + const completedBeforeWrite = completed; + await act(async () => { + if (outcome === 'success') write.resolve({ noteEffect: true }); + else write.reject(new Error('settings rejected')); + await settlement; + }); + expect(completedBeforeWrite).toBe(false); + await expect(settlement).resolves.toBe(outcome === 'success'); + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === 'app_quit_after_editor_flush', + ), + ).toHaveLength(outcome === 'success' ? 1 : 0); + }, + ); + + it('정산 직전 시작한 설정 저장의 빠른 실패를 놓치지 않는다', async () => { + const write = arrangeWrite(); + const saving = settingsApi.update({ noteEffect: true }).catch(() => {}); + const settlement = flushFocusedEditor(); + write.reject(new Error('already pending settings failed')); + + await saving; + await expect(settlement).resolves.toBe(false); + }); + + it('coordinator flush 중 끝난 설정 실패가 뒤늦은 ack로 바뀌지 않는다', async () => { + const coordinatorFlush = deferred(); + mocks.flush.mockReturnValue(coordinatorFlush.promise); + const write = arrangeWrite(); + const ack = acknowledgeLifecycleAfterEditorFlush('late-failure'); + const acknowledged = ack.then( + () => true, + () => false, + ); + await vi.waitFor(() => expect(mocks.flush).toHaveBeenCalledOnce()); + const saving = settingsApi.update({ noteEffect: true }).catch(() => {}); + write.reject(new Error('failed during coordinator flush')); + await saving; + coordinatorFlush.resolve(); + + await expect(acknowledged).resolves.toBe(false); + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === 'app_quit_after_editor_flush', + ), + ).toHaveLength(0); + }); + + it('ack 진입 전에 시작된 저장도 coordinator를 기다리는 동안 추적한다', async () => { + const write = arrangeWrite(); + const saving = settingsApi.update({ noteEffect: true }).catch(() => {}); + const ack = acknowledgeLifecycleAfterEditorFlush('already-saving'); + const acknowledged = ack.then( + () => true, + () => false, + ); + write.reject(new Error('in-flight settings failed')); + + await saving; + await expect(acknowledged).resolves.toBe(false); + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === 'app_quit_after_editor_flush', + ), + ).toHaveLength(0); + }); +}); diff --git a/src/renderer/editor/runtime/focusedEditorSettlement.deferred.test.tsx b/src/renderer/editor/runtime/focusedEditorSettlement.deferred.test.tsx new file mode 100644 index 000000000..07569c35c --- /dev/null +++ b/src/renderer/editor/runtime/focusedEditorSettlement.deferred.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { act, useRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAfterPaintValueCommit } from '@hooks/useAfterPaintValueCommit'; +import { useOptimisticBooleanCommit } from '@hooks/useOptimisticBooleanCommit'; +import { useOptimisticValueCommit } from '@hooks/useOptimisticValueCommit'; +import { registerPendingOptimisticCommit } from '@hooks/pendingOptimisticCommits'; +import { trackEditorWrite } from './editorWriteBarrier'; +import { settleFocusedEditor } from './focusedEditorSettlement'; + +type CommitKind = 'after-paint' | 'boolean' | 'value'; + +interface DeferredCommitHarnessProps { + kind: CommitKind; + onCommit: () => void; +} + +const DeferredCommitHarness = ({ + kind, + onCommit, +}: DeferredCommitHarnessProps) => { + const ref = useRef(null); + const afterPaint = useAfterPaintValueCommit({ + onCommit, + frameHostRef: ref, + }); + const boolean = useOptimisticBooleanCommit({ + canonicalValue: false, + onCommit, + frameHostRef: ref, + }); + const value = useOptimisticValueCommit({ + canonicalValue: 'before', + onCommit, + frameHostRef: ref, + }); + return ( + + + ), useBatchHandlers: (props: Record) => { batchPropsMock(props); return { @@ -680,6 +716,61 @@ describe('PropertiesPanel canonical native contract', () => { window.__dmn_window_type = originalWindowType; }); + it('헤더 이름을 Escape로 취소한 뒤 다음 이름을 저장한다', async () => { + const id = '61111111-1111-4111-8111-111111111111'; + installSingle('key', id); + mounted = mountPanel(); + const container = mounted.container; + const startRename = async () => { + await act(async () => + container + .querySelector( + 'button[title="contextMenu.rename"]', + )! + .click(), + ); + const input = container.querySelector('input')!; + await act(async () => input.focus()); + return input; + }; + const typeName = async (input: HTMLInputElement, value: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + const cancelled = await startRename(); + await typeName(cancelled, 'Cancelled'); + const blur = vi.fn(); + cancelled.addEventListener('blur', blur); + await act(async () => + cancelled.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ), + ); + expect(container.querySelector('input')).toBeNull(); + expect(blur).not.toHaveBeenCalled(); + expect(patchLayerNameMock).not.toHaveBeenCalled(); + + const input = await startRename(); + await typeName(input, 'Saved'); + await act(async () => + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ), + ); + + expect(patchLayerNameMock).toHaveBeenCalledExactlyOnceWith( + 'key', + id, + 'Saved', + ); + }); + it.each([ ['key', '11111111-1111-4111-8111-111111111111'], ['stat', '22222222-2222-4222-8222-222222222222'], @@ -1328,6 +1419,65 @@ describe('PropertiesPanel 편집 세션 scope 경계', () => { expect(scopedOf('plugin-settings')).toBe('false'); }); + + it('실제 플러그인 설정 세션의 저장 실패를 한 번 정산하고 안내한다', async () => { + const originalApi = window.api; + const alert = vi.fn().mockResolvedValue(undefined); + const onCancel = vi.fn(); + const resolve = vi.fn(); + const originalSettings = { enabled: false }; + let rejectSave!: (error: Error) => void; + const onConfirm = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSave = reject; + }), + ); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + window.api = { ui: { dialog: { alert } } } as never; + try { + openPluginSettingsSession({ + pluginId: 'save-test', + definition: { settings: {} }, + settings: { enabled: true }, + originalSettings, + onChange: vi.fn(), + onConfirm, + onCancel, + resolve, + }); + mounted = mountPanel(); + + await act(async () => { + const button = [...mounted.container.querySelectorAll('button')].find( + (element) => element.textContent === 'save-plugin-settings', + ); + expect(button).toBeDefined(); + button!.click(); + }); + + expect(onConfirm).toHaveBeenCalledExactlyOnceWith( + { enabled: true }, + originalSettings, + ); + expect(usePropertiesPanelStore.getState().pluginSettingsPanel).toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + await act(async () => rejectSave(new Error('disk write failed'))); + + expect(onCancel).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledExactlyOnceWith(false); + expect(alert).toHaveBeenCalledExactlyOnceWith('common.saveFailed', { + confirmText: 'common.ok', + }); + expect(usePropertiesPanelStore.getState().pluginSettingsPanel).toBeNull(); + } finally { + cancelPluginSettingsSessionForPlugin('save-test'); + window.api = originalApi; + consoleError.mockRestore(); + } + }); }); describe('PropertiesPanel 배치 색상 피커 대상 결합', () => { diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 222fa91da..999b83edc 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -991,6 +991,7 @@ const PropertiesPanel: React.FC = ({ // 레이어 이름 변경 시작 const handleRenameStartImpl = useRef<() => void>(() => {}); handleRenameStartImpl.current = () => { + renameCancelledRef.current = false; const current = getCurrentLayerName(); setRenameValue(current || getCurrentDefaultTitle()); setIsRenaming(true); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.rename.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.rename.test.tsx new file mode 100644 index 000000000..196ea66ef --- /dev/null +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.rename.test.tsx @@ -0,0 +1,205 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + renameLayer: vi.fn(() => Promise.resolve(true)), + renameGroup: vi.fn(() => Promise.resolve(true)), +})); + +vi.mock('@components/main/common/IconSwap', () => ({ + default: () => , +})); +vi.mock('@src/renderer/editor/runtime/elementOps', () => ({ + patchElementHiddenById: vi.fn(), + patchElementLayerNameById: mocks.renameLayer, + renameLayerGroupById: mocks.renameGroup, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ + scrollContainerRef: () => {}, + lenisInstance: { current: null }, + }), +})); +vi.mock('@components/main/Modal/ListPopup', () => ({ + default: ({ + open, + items, + onSelect, + }: { + open: boolean; + items: { id: string; label: string }[]; + onSelect: (id: string) => void; + }) => + open ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : null, +})); + +import { useKeyStore } from '@stores/data/useKeyStore'; +import { useStatItemStore } from '@stores/data/useStatItemStore'; +import { useGraphItemStore } from '@stores/data/useGraphItemStore'; +import { useKnobItemStore } from '@stores/data/useKnobItemStore'; +import { useSpriteStore } from '@stores/data/useSpriteStore'; +import { useLayerGroupStore } from '@stores/data/useLayerGroupStore'; +import { useGridSelectionStore } from '@stores/grid/useGridSelectionStore'; +import { usePluginDisplayElementStore } from '@stores/plugin/usePluginDisplayElementStore'; +import LayerTabContent from './LayerTabContent'; + +const KEY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const GROUP_ID = 'group-a'; + +describe('레이어 이름 입력의 취소와 저장 경계', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + mocks.renameLayer.mockClear(); + mocks.renameGroup.mockClear(); + const positions = { + '4key': [ + { + id: KEY_ID, + dx: 0, + dy: 0, + width: 60, + height: 60, + groupId: GROUP_ID, + layerName: 'Key A', + }, + ], + }; + useKeyStore.setState({ + selectedKeyType: '4key', + keyMappings: { '4key': ['A'] }, + positions: positions as never, + canonicalPositions: positions as never, + }); + useStatItemStore.setState({ positions: {} }); + useGraphItemStore.setState({ positions: {} }); + useKnobItemStore.setState({ positions: {} }); + useSpriteStore.setState({ positions: {} }); + useLayerGroupStore.setState({ + layerGroups: { '4key': [{ id: GROUP_ID, name: 'Group A' }] }, + collapsedGroups: new Set(), + }); + usePluginDisplayElementStore.setState({ + elements: [ + { + id: 'item', + fullId: 'plugin:item', + pluginId: 'plugin', + definitionId: 'Plugin A', + html: '', + position: { x: 0, y: 0 }, + }, + ], + }); + useGridSelectionStore.setState({ + selectedElements: [], + selectedGroupIds: [], + }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => root.render()); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + }); + + const openMenu = async (name: string) => { + const row = [...host.querySelectorAll('.dmn-row-grabbable')].find( + (element) => element.textContent === name, + ); + expect(row).toBeDefined(); + await act(async () => { + row!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true })); + }); + return document.querySelector('[data-testid="layer-menu"]')!; + }; + + const startRename = async (type: 'layer' | 'group') => { + const menu = await openMenu(type === 'layer' ? 'Key A' : 'Group A'); + const label = + type === 'layer' ? 'contextMenu.rename' : 'contextMenu.renameGroup'; + const button = [...menu.querySelectorAll('button')].find( + (element) => element.textContent === label, + ); + expect(button).toBeDefined(); + await act(async () => button!.click()); + const input = host.querySelector('input')!; + expect(input).not.toBeNull(); + await act(async () => input.focus()); + return input; + }; + + const typeName = async (input: HTMLInputElement, value: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + const pressKey = async (input: HTMLInputElement, key: string) => { + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + }); + }; + + it.each(['layer', 'group'] as const)( + '%s 이름을 Escape로 취소한 뒤 새 이름을 Enter로 저장한다', + async (type) => { + const cancelled = await startRename(type); + await typeName(cancelled, 'Cancelled'); + const blur = vi.fn(); + cancelled.addEventListener('blur', blur); + await pressKey(cancelled, 'Escape'); + + expect(host.querySelector('input')).toBeNull(); + expect(blur).not.toHaveBeenCalled(); + expect(mocks.renameLayer).not.toHaveBeenCalled(); + expect(mocks.renameGroup).not.toHaveBeenCalled(); + + const input = await startRename(type); + await typeName(input, 'Saved'); + await pressKey(input, 'Enter'); + + if (type === 'layer') { + expect(mocks.renameLayer).toHaveBeenCalledExactlyOnceWith( + 'key', + KEY_ID, + 'Saved', + ); + } else { + expect(mocks.renameGroup).toHaveBeenCalledExactlyOnceWith( + '4key', + GROUP_ID, + 'Saved', + ); + } + }, + ); + + it('인스턴스 이름 저장을 지원하지 않는 플러그인에는 이름 변경을 제공하지 않는다', async () => { + const menu = await openMenu('Plugin A'); + expect(menu.textContent).not.toContain('contextMenu.rename'); + expect(menu.textContent).toContain('propertiesPanel.delete'); + }); +}); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx index 371dfe1fd..42c2bf454 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.test.tsx @@ -256,6 +256,21 @@ describe('useLayerActions visibility routing', () => { expect(mocks.reportSkipped).not.toHaveBeenCalled(); }); + it('플러그인 이름 변경은 직접 메뉴 액션으로도 시작하지 않는다', async () => { + const item: LayerItem = { + type: 'plugin', + id: 'plugin-a:one', + name: 'plugin', + zIndex: 0, + hidden: false, + }; + await act(async () => actions.handleContextMenu(click, item, 0)); + await act(async () => actions.handleContextMenuSelect('rename')); + + expect(actions.renamingItemId).toBeNull(); + expect(mocks.patchLayerName).not.toHaveBeenCalled(); + }); + it('main stable group은 allHidden에서 계산한 absolute literal만 semantic helper에 넘긴다', async () => { await exposeWithItems([ { diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts index d9a1c80fb..831f01be1 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/useLayerActions.ts @@ -354,9 +354,10 @@ export function useLayerActions({ } const selectedElements = useGridSelectionStore.getState().selectedElements; - const items: ListItem[] = [ - { id: 'rename', label: t('contextMenu.rename') || 'Rename' }, - ]; + const items: ListItem[] = + contextMenuItem?.type === 'plugin' + ? [] + : [{ id: 'rename', label: t('contextMenu.rename') || 'Rename' }]; if (selectedElements.length >= 2 && !contextMenuItem?.groupId) { items.push({ @@ -439,6 +440,7 @@ export function useLayerActions({ const groupDef = layerGroupsForMode.find( (g) => g.id === contextMenuGroupId, ); + renameCancelledRef.current = false; setRenamingItemId(`group:${contextMenuGroupId}`); setRenameValue(groupDef?.name || ''); setContextMenuOpen(false); @@ -476,7 +478,8 @@ export function useLayerActions({ } if (itemId === 'rename') { - if (contextMenuItem) { + if (contextMenuItem && contextMenuItem.type !== 'plugin') { + renameCancelledRef.current = false; setRenamingItemId(contextMenuItem.id); setRenameValue(contextMenuItem.name); setContextMenuOpen(false); diff --git a/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx b/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx index 293f42529..2cded2a60 100644 --- a/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabCssModal.tsx @@ -188,6 +188,7 @@ const TabCssModal = ({ isOpen, onClose, showAlert }: TabCssModalProps) => { onCommit: commitCssEnabled, onError: (error) => { console.error('Failed to toggle tab CSS:', error); + showAlert?.(t('common.saveFailed')); }, }); diff --git a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx index 5257cb6e7..3de58b1a2 100644 --- a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.test.tsx @@ -12,8 +12,12 @@ const testState = vi.hoisted(() => ({ selectedKeyType: 'tab-a', noteEffect: true, noteSettings: null as unknown, + keyMappings: {} as Record, + customTabs: [] as { id: string }[], +})); +const apiMocks = vi.hoisted(() => ({ + noteTabSet: vi.fn(), })); -const apiMocks = vi.hoisted(() => ({ noteTabSet: vi.fn() })); vi.mock('@api/modules/noteTabApi', () => ({ noteTabApi: { @@ -22,8 +26,10 @@ vi.mock('@api/modules/noteTabApi', () => ({ })); vi.mock('@stores/data/useKeyStore', () => ({ - useKeyStore: (selector: (state: { selectedKeyType: string }) => unknown) => - selector({ selectedKeyType: testState.selectedKeyType }), + useKeyStore: Object.assign( + (selector: (state: typeof testState) => unknown) => selector(testState), + { getState: () => testState }, + ), })); vi.mock('@stores/useSettingsStore', () => ({ @@ -43,29 +49,33 @@ vi.mock('@contexts/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock('@hooks/ui/usePopupPresence', () => ({ - useModalPresence: (open: boolean) => ({ - mounted: open, - state: 'open', - cycle: open ? 1 : 0, - }), +vi.mock('@components/main/common/Checkbox', () => ({ + default: () => , })); - -vi.mock('../settings/NoteSetting', () => ({ - default: ({ - settings, - onSave, +vi.mock('@components/main/common/TabSwitch', () => ({ + default: () => , +})); +vi.mock('@components/main/Grid/PropertiesPanel/PropertyInputs', () => ({ + PropertyRow: ({ + label, + children, }: { - settings: NoteSettings; - onSave: (settings: NoteSettings) => Promise; - }) => ( -
- -
+ label: string; + children: React.ReactNode; + }) => , + PropertySection: ({ children }: { children: React.ReactNode }) => ( +
{children}
), })); +vi.mock('../../Modal', () => ({ + default: ({ + children, + motionState, + }: { + children: React.ReactNode; + motionState?: string; + }) =>
{children}
, +})); import TabNoteSettingModal from './TabNoteSettingModal'; @@ -82,17 +92,50 @@ const deferred = (): Deferred => { return { promise, resolve }; }; -describe('TabNoteSettingModal 로드 세대', () => { +describe('TabNoteSettingModal 편집 세션', () => { let container: HTMLDivElement; let root: Root; let noteTabGet: ReturnType; let noteTabSet: ReturnType; + const speedInput = () => + container.querySelector( + '[data-label="noteSetting.speed"] input', + ); + const saveButton = () => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'noteSetting.save', + )!; + const editSpeed = (speed: number) => + act(() => { + const input = speedInput()!; + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(input, String(speed)); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; testState.selectedKeyType = 'tab-a'; testState.noteEffect = true; testState.noteSettings = getDefaultNoteSettings(); + testState.keyMappings = { 'tab-a': [], 'tab-b': [] }; + testState.customTabs = []; + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1), + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); noteTabGet = vi.fn(); noteTabSet = apiMocks.noteTabSet; noteTabSet.mockReset().mockResolvedValue({ success: true, tabId: 'tab-b' }); @@ -113,6 +156,7 @@ describe('TabNoteSettingModal 로드 세대', () => { act(() => root.unmount()); container.remove(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('닫힌 이전 탭의 늦은 응답을 버리고 현재 탭 설정만 저장한다', async () => { @@ -145,28 +189,171 @@ describe('TabNoteSettingModal 로드 세대', () => { await Promise.resolve(); }); - expect( - container - .querySelector('[data-note-speed]') - ?.getAttribute('data-note-speed'), - ).toBe(String(settingsB.speed)); + expect(speedInput()?.value).toBe(String(settingsB.speed)); await act(async () => { first.resolve({ tabId: 'tab-a', settings: settingsA }); await Promise.resolve(); }); - expect( - container - .querySelector('[data-note-speed]') - ?.getAttribute('data-note-speed'), - ).toBe(String(settingsB.speed)); + expect(speedInput()?.value).toBe(String(settingsB.speed)); await act(async () => { - container.querySelector('button')?.click(); + saveButton().click(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); expect(noteTabSet).toHaveBeenCalledWith('tab-b', settingsB); }); + + it('저장 실패를 편집 화면에 전파해서 성공으로 닫히지 않게 한다', async () => { + noteTabGet.mockResolvedValue({ tabId: 'tab-a', settings: { speed: 700 } }); + const error = new Error('tab settings write failed'); + const onClose = vi.fn(); + noteTabSet.mockRejectedValueOnce(error); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => { + root.render(); + }); + await act(async () => { + saveButton().click(); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(speedInput()?.value).toBe('700'); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'common.saveFailed', + ); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenLastCalledWith('tab-a', { speed: 700 }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('저장 press 직후 외부 모드가 바뀌어도 처음 연 탭에 저장한다', async () => { + const firstSpeed = (testState.noteSettings as NoteSettings).speed + 10; + const second = deferred(); + noteTabGet + .mockResolvedValueOnce({ + tabId: 'tab-a', + settings: { speed: firstSpeed }, + }) + .mockImplementationOnce(() => second.promise); + const onClose = vi.fn(); + await act(async () => { + root.render(); + }); + editSpeed(firstSpeed + 7); + act(() => { + const save = saveButton(); + save.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + pointerId: 9, + isPrimary: true, + button: 0, + }), + ); + save.dispatchEvent( + new PointerEvent('pointerup', { + bubbles: true, + pointerId: 9, + isPrimary: true, + button: 0, + }), + ); + testState.selectedKeyType = 'tab-b'; + root.render(); + }); + expect(speedInput()?.value).toBe(String(firstSpeed + 7)); + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + expect(noteTabSet).toHaveBeenCalledWith('tab-a', { speed: firstSpeed + 7 }); + expect(noteTabGet).toHaveBeenCalledTimes(1); + }); + + it('조회 중 모드가 바뀌어도 원래 탭을 열고 재개방할 때 새 탭을 읽는다', async () => { + const first = deferred(); + const second = deferred(); + noteTabGet + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + const onClose = vi.fn(); + await act(async () => + root.render(), + ); + testState.selectedKeyType = 'tab-b'; + await act(async () => + root.render(), + ); + await act(async () => + first.resolve({ tabId: 'tab-a', settings: { speed: 710 } }), + ); + expect(speedInput()?.value).toBe('710'); + expect(noteTabGet).toHaveBeenCalledTimes(1); + + await act(async () => + root.render(), + ); + await act(async () => + root.render(), + ); + await act(async () => + second.resolve({ tabId: 'tab-b', settings: { speed: 820 } }), + ); + expect(speedInput()?.value).toBe('820'); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenCalledWith('tab-b', { speed: 820 }); + }); + + it('열었던 탭이 삭제되면 다른 탭에 쓰지 않고 안내와 초안을 유지한다', async () => { + noteTabGet.mockResolvedValue({ tabId: 'tab-a', settings: { speed: 700 } }); + const onClose = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => + root.render(), + ); + editSpeed(777); + + testState.selectedKeyType = 'tab-b'; + testState.keyMappings = { 'tab-b': [] }; + await act(async () => + root.render(), + ); + await act(async () => saveButton().click()); + + expect(noteTabSet).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(speedInput()?.value).toBe('777'); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'common.editTargetMissing', + ); + + await act(async () => + root.render(), + ); + noteTabGet.mockResolvedValueOnce({ + tabId: 'tab-b', + settings: { speed: 820 }, + }); + await act(async () => + root.render(), + ); + expect(speedInput()?.value).toBe('820'); + expect(container.querySelector('[role="alert"]')).toBeNull(); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenCalledWith('tab-b', { speed: 820 }); + }); + + it('아직 키가 없는 사용자 탭도 존재하면 저장한다', async () => { + testState.keyMappings = { 'tab-b': [] }; + testState.customTabs = [{ id: 'tab-a' }]; + noteTabGet.mockResolvedValue({ tabId: 'tab-a', settings: { speed: 700 } }); + const onClose = vi.fn(); + await act(async () => + root.render(), + ); + await act(async () => saveButton().click()); + expect(noteTabSet).toHaveBeenCalledWith('tab-a', { speed: 700 }); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx index 929971ae6..845627203 100644 --- a/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabNoteSettingModal.tsx @@ -9,8 +9,8 @@ import type { NoteSettings, TabNoteSettings, } from '@src/types/settings/noteSettings'; -import { useTranslation } from '@contexts/useTranslation'; import { noteTabApi } from '@api/modules/noteTabApi'; +import { useTranslation } from '@contexts/useTranslation'; interface TabNoteSettingModalProps { isOpen: boolean; @@ -18,14 +18,26 @@ interface TabNoteSettingModalProps { } const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { - const { t: _t } = useTranslation(); + const { t } = useTranslation(); const globalSettings = useSettingsStore((s) => s.noteSettings); const noteEffect = useSettingsStore((s) => s.noteEffect); const selectedKeyType = useKeyStore((s) => s.selectedKeyType); const [tabOverride, setTabOverride] = useState(null); const [loading, setLoading] = useState(true); + const [editingTabId, setEditingTabId] = useState(selectedKeyType); + const [wasOpen, setWasOpen] = useState(isOpen); + const [saveErrorMessage, setSaveErrorMessage] = useState(null); const loadGenerationRef = useRef(0); + if (isOpen !== wasOpen) { + setWasOpen(isOpen); + if (isOpen) { + setEditingTabId(selectedKeyType); + setLoading(true); + setSaveErrorMessage(null); + } + } + // 모달 열릴 때 현재 탭의 오버라이드 로드 useEffect(() => { if (!isOpen) return; @@ -35,7 +47,7 @@ const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { setLoading(true); window.api.noteTab - .get(selectedKeyType) + .get(editingTabId) .then((res) => { if (!isCurrentGeneration()) return; setTabOverride(res.settings ?? null); @@ -56,25 +68,30 @@ const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { loadGenerationRef.current += 1; } }; - }, [isOpen, selectedKeyType]); + }, [isOpen, editingTabId]); const handleSave = async (normalized: NoteSettings) => { - try { - // 전역 설정과 비교하여 다른 값만 오버라이드로 저장 - const override: TabNoteSettings = {}; - const keys = Object.keys(normalized) as (keyof NoteSettings)[]; - for (const key of keys) { - if (normalized[key] !== globalSettings[key]) { - (override as Record)[key] = - normalized[key]; - } + const { keyMappings, customTabs } = useKeyStore.getState(); + if ( + !Object.prototype.hasOwnProperty.call(keyMappings, editingTabId) && + !customTabs.some((tab) => tab.id === editingTabId) + ) { + setSaveErrorMessage(t('common.editTargetMissing')); + throw new Error('Tab note settings target no longer exists'); + } + setSaveErrorMessage(null); + // 전역 설정과 비교하여 다른 값만 오버라이드로 저장 + const override: TabNoteSettings = {}; + const keys = Object.keys(normalized) as (keyof NoteSettings)[]; + for (const key of keys) { + if (normalized[key] !== globalSettings[key]) { + (override as Record)[key] = + normalized[key]; } - // 모든 값이 전역과 동일하면 오버라이드 제거 - const hasOverride = Object.keys(override).length > 0; - await noteTabApi.set(selectedKeyType, hasOverride ? override : null); - } catch (error) { - console.error('Failed to save tab note settings', error); } + // 모든 값이 전역과 동일하면 오버라이드 제거 + const hasOverride = Object.keys(override).length > 0; + await noteTabApi.set(editingTabId, hasOverride ? override : null); }; // 퇴장 모션이 도는 동안 DOM을 유지한다 @@ -96,6 +113,7 @@ const TabNoteSettingModal = ({ isOpen, onClose }: TabNoteSettingModalProps) => { settings={mergedSettings} onClose={onClose} onSave={handleSave} + saveErrorMessage={saveErrorMessage} /> ); }; diff --git a/src/renderer/components/main/Modal/content/settings/NoteSetting.test.tsx b/src/renderer/components/main/Modal/content/settings/NoteSetting.test.tsx index 2578a9a6e..07a21cca2 100644 --- a/src/renderer/components/main/Modal/content/settings/NoteSetting.test.tsx +++ b/src/renderer/components/main/Modal/content/settings/NoteSetting.test.tsx @@ -13,7 +13,11 @@ vi.mock('@contexts/useTranslation', () => ({ }), })); vi.mock('@hooks/usePressAction', () => ({ - usePressAction: (action?: () => void) => ({ onClick: action }), + usePressAction: (action?: () => void) => ({ + onClick: () => { + void Promise.resolve(action?.()).catch(() => undefined); + }, + }), })); vi.mock('@components/main/common/Checkbox', () => ({ default: ({ @@ -68,8 +72,16 @@ vi.mock('@components/main/Grid/PropertiesPanel/PropertyInputs', () => ({ ), })); vi.mock('../../Modal', () => ({ - default: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ default: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( +
+ {children} +
), })); @@ -152,4 +164,100 @@ describe('NoteSetting 단노트 정책 안내', () => { ) as HTMLInputElement; expect(input.max).toBe('30000'); }); + + it('저장이 실패하면 수정한 초안을 유지하고 같은 값으로 재시도한다', async () => { + const error = new Error('note settings write failed'); + const onSave = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(undefined); + const onClose = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + act(() => { + root.render( + , + ); + }); + const speedInput = container.querySelector( + '[data-label="noteSetting.speed"] input', + )!; + act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!.call(speedInput, '777'); + speedInput.dispatchEvent(new Event('input', { bubbles: true })); + }); + const save = () => + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'noteSetting.save', + )!; + await act(async () => { + save().click(); + }); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ speed: 777 }), + ); + expect(onClose).not.toHaveBeenCalled(); + expect(speedInput.value).toBe('777'); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'common.saveFailed', + ); + + await act(async () => { + save().click(); + }); + expect(onSave).toHaveBeenCalledTimes(2); + expect(onSave.mock.calls[1][0]).toEqual(onSave.mock.calls[0][0]); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('저장 응답 전 중복 저장과 닫기를 막고 입력을 잠근다', async () => { + let resolveSave!: () => void; + const onSave = vi.fn( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const onClose = vi.fn(); + act(() => { + root.render( + , + ); + }); + const buttons = Array.from(container.querySelectorAll('button')); + const save = buttons.find( + (button) => button.textContent === 'noteSetting.save', + )!; + const cancel = buttons.find( + (button) => button.textContent === 'noteSetting.cancel', + )!; + await act(async () => { + save.click(); + save.click(); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(save.disabled).toBe(true); + expect(cancel.disabled).toBe(true); + expect(container.querySelector('input')?.matches(':disabled')).toBe(true); + act(() => { + cancel.click(); + container.querySelector('[data-backdrop]')!.click(); + }); + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => resolveSave()); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx b/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx index 403e38c2b..20479df59 100644 --- a/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx +++ b/src/renderer/components/main/Modal/content/settings/NoteSetting.tsx @@ -1,4 +1,5 @@ import { usePressAction } from '@hooks/usePressAction'; +import { useSingleFlightAction } from '@hooks/useSingleFlightAction'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import Checkbox from '@components/main/common/Checkbox'; import TabSwitch from '@components/main/common/TabSwitch'; @@ -44,6 +45,7 @@ interface NoteSettingProps { onClose?: () => void; settings: NoteSettings | null; onSave?: (normalized: NoteSettings) => Promise | void; + saveErrorMessage?: string | null; title?: string | null; /** 수명은 호출부가 presence로 소유한다 */ motionState?: PopupMotionState; @@ -53,12 +55,14 @@ const NoteSetting = ({ onClose, settings, onSave, + saveErrorMessage, title = null, motionState, }: NoteSettingProps) => { const { t } = useTranslation(); const initial: Partial = settings || {}; const [activeTab, setActiveTab] = useState(NOTE_TAB); + const [saveFailed, setSaveFailed] = useState(false); const [frameLimit, setFrameLimit] = useState( String(sanitizeNumericValue(initial.frameLimit, 'frameLimit')), @@ -188,44 +192,49 @@ const NoteSetting = ({ } }, [isAnimating, disableHeightTransition]); - const handleSave = async () => { - const normalized = { - ...settings, - frameLimit: sanitizeNumericValue(frameLimit, 'frameLimit'), - speed: sanitizeNumericValue(speed, 'speed'), - trackHeight: sanitizeNumericValue(trackHeight, 'trackHeight'), - reverse, - fadeTopPx: sanitizeNumericValue(fadeTopPx, 'fadeTopPx'), - fadeBottomPx: sanitizeNumericValue(fadeBottomPx, 'fadeBottomPx'), - reverseFadeTopPx: sanitizeNumericValue( - reverseFadeTopPx, - 'reverseFadeTopPx', - ), - reverseFadeBottomPx: sanitizeNumericValue( - reverseFadeBottomPx, - 'reverseFadeBottomPx', - ), - delayedNoteEnabled, - shortNoteThresholdMs: sanitizeNumericValue( - shortNoteThresholdMs, - 'shortNoteThresholdMs', - ), - shortNoteMinLengthPx: sanitizeNumericValue( - shortNoteMinLengthPx, - 'shortNoteMinLengthPx', - ), - keyDisplayDelayMs: sanitizeNumericValue( - keyDisplayDelayMs, - 'keyDisplayDelayMs', - ), - }; + const { run: handleSave, pending: savePending } = useSingleFlightAction( + async () => { + const normalized = { + ...settings, + frameLimit: sanitizeNumericValue(frameLimit, 'frameLimit'), + speed: sanitizeNumericValue(speed, 'speed'), + trackHeight: sanitizeNumericValue(trackHeight, 'trackHeight'), + reverse, + fadeTopPx: sanitizeNumericValue(fadeTopPx, 'fadeTopPx'), + fadeBottomPx: sanitizeNumericValue(fadeBottomPx, 'fadeBottomPx'), + reverseFadeTopPx: sanitizeNumericValue( + reverseFadeTopPx, + 'reverseFadeTopPx', + ), + reverseFadeBottomPx: sanitizeNumericValue( + reverseFadeBottomPx, + 'reverseFadeBottomPx', + ), + delayedNoteEnabled, + shortNoteThresholdMs: sanitizeNumericValue( + shortNoteThresholdMs, + 'shortNoteThresholdMs', + ), + shortNoteMinLengthPx: sanitizeNumericValue( + shortNoteMinLengthPx, + 'shortNoteMinLengthPx', + ), + keyDisplayDelayMs: sanitizeNumericValue( + keyDisplayDelayMs, + 'keyDisplayDelayMs', + ), + }; - try { - await onSave?.(normalized as NoteSettings); - } finally { - onClose?.(); - } - }; + setSaveFailed(false); + try { + await onSave?.(normalized as NoteSettings); + onClose?.(); + } catch (error) { + console.error('Failed to save note settings', error); + setSaveFailed(true); + } + }, + ); const renderNoteTab = () => (
@@ -457,12 +466,14 @@ const NoteSetting = ({ // 입력 blur·IME flush와의 경합으로 첫 click이 유실되는 것을 방어 const savePress = usePressAction(handleSave); - const cancelPress = usePressAction(onClose); + const cancelPress = usePressAction(() => { + if (!savePending) onClose?.(); + }); return ( @@ -504,19 +515,29 @@ const NoteSetting = ({ }} >
- {activeTab === NOTE_TAB ? renderNoteTab() : renderAdvancedTab()} +
+ {activeTab === NOTE_TAB ? renderNoteTab() : renderAdvancedTab()} +
+ {saveFailed && ( +

+ {saveErrorMessage ?? t('common.saveFailed')} +

+ )} +
+ ), +})); +vi.mock('@components/main/common/SettingRow', () => ({ + SettingCard: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SettingRow: ({ + label, + children, + }: { + label: React.ReactNode; + children: React.ReactNode; + }) => ( +
+ {label} + {children} +
+ ), + SettingToggleRow: ({ + label, + checked, + onToggle, + }: { + label: string; + checked: boolean; + onToggle: () => void; + }) => ( + + ), +})); +vi.mock('@components/main/SettingsPreview', () => ({ default: () => null })); +vi.mock('@components/main/SettingsPanel/ShortcutsPanelContent', () => ({ + default: () => null, +})); +vi.mock('@components/main/SettingsPanel/CssPanelContent', () => ({ + default: () => null, +})); +vi.mock('@components/main/SettingsPanel/SettingsSidePanel', () => ({ + default: ({ + activePanel, + pages, + }: { + activePanel: string; + pages: { key: string; content: React.ReactNode }[]; + }) => pages.find((page) => page.key === activePanel)?.content, +})); +vi.mock('@components/main/SettingsPanel/PluginsPanelContent', () => ({ + default: ({ onRemove }: { onRemove: (id: string) => void }) => ( + + ), +})); +vi.mock('@components/main/Modal/content/dialogs/PluginDataDeleteModal', () => ({ + PluginDataDeleteModal: ({ + isOpen, + onConfirm, + }: { + isOpen: boolean; + onConfirm: (withData: boolean) => void; + }) => + isOpen ? ( + + ) : null, +})); + +import Settings from './Settings'; + +describe('Settings 실패 안내와 상태 보존', () => { + let root: Root; + let host: HTMLDivElement; + const showAlert = vi.fn(); + const showConfirm = vi.fn(); + const button = (text: string) => + Array.from(host.querySelectorAll('button')).find( + (node) => node.textContent === text, + )!; + const choose = (label: string, value: string) => + act(() => { + const select = host.querySelector( + `[data-label="${label}"] select`, + )!; + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + const confirm = () => + act(async () => { + await showConfirm.mock.lastCall![1](); + }); + + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('__APP_VERSION__', 'test'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const mock of Object.values(mocks)) + mock.mockReset().mockResolvedValue(undefined); + mocks.hasPluginData.mockResolvedValue(true); + showAlert.mockReset(); + showConfirm.mockReset(); + mocks.getSoundState.mockResolvedValue({ + requested: { kind: 'defaultDevice' }, + effective: { kind: 'defaultDevice' }, + error: null, + errorCode: null, + asioAvailable: false, + }); + useSettingsStore.setState({ + ...getDefaultSettingsState(), + language: 'ko', + angleMode: 'd3d11', + jsPlugins: [ + { + id: 'plugin-a', + name: 'plugin-a.js', + content: '// @id plugin-a', + enabled: true, + }, + ] as never, + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render(), + ); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('전체 초기화가 실패하면 선택과 데이터를 보존하고 안내한다', async () => { + const before = useKeyStore.getState().keyMappings; + const selection = [{ type: 'key' as const, id: 'key-a', index: 0 }]; + useGridSelectionStore.getState().setSelectedElements(selection); + mocks.resetAll.mockRejectedValue(new Error('reset failed')); + act(() => button('settings.resetData').click()); + await confirm(); + expect(mocks.resetAll).toHaveBeenCalledOnce(); + expect(useKeyStore.getState().keyMappings).toBe(before); + expect(useGridSelectionStore.getState().selectedElements).toEqual( + selection, + ); + expect(showAlert).toHaveBeenCalledWith('common.actionFailed'); + }); + + it('OBS 토큰 재생성이 실패하면 자동 재시도 없이 안내한다', async () => { + mocks.regenerateToken.mockRejectedValue(new Error('token failed')); + act(() => button('settings.obsTokenRegen').click()); + await confirm(); + expect(mocks.regenerateToken).toHaveBeenCalledOnce(); + expect(showAlert).toHaveBeenCalledWith('common.actionFailed'); + }); + + it('각도 모드 저장이 실패하면 기존 모드를 유지하고 재시작하지 않는다', async () => { + mocks.update.mockRejectedValue(new Error('settings failed')); + choose('settings.graphicsOption', 'gl'); + await confirm(); + expect(useSettingsStore.getState().angleMode).toBe('d3d11'); + expect(mocks.restart).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('각도 모드 저장 후 재시작만 실패하면 저장한 모드를 유지한다', async () => { + mocks.restart.mockRejectedValue(new Error('restart failed')); + choose('settings.graphicsOption', 'gl'); + await confirm(); + expect(useSettingsStore.getState().angleMode).toBe('gl'); + expect(showAlert).toHaveBeenCalledWith('common.restartFailed'); + }); + + it('언어 저장 실패가 기존 선택을 바꾸지 않는다', async () => { + const failure = Promise.reject(new Error('language failed')); + void failure.catch(() => {}); + mocks.changeLanguage.mockReturnValue(failure); + choose('settings.language', 'en'); + await act(async () => { + await Promise.resolve(); + }); + expect(useSettingsStore.getState().language).toBe('ko'); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('플러그인 제거가 거절되면 저장된 데이터를 삭제하지 않는다', async () => { + mocks.removePlugin.mockResolvedValue({ success: false }); + act(() => button('settings.managePlugins').click()); + await act(async () => + host.querySelector('[data-remove-plugin]')!.click(), + ); + await act(async () => + host.querySelector('[data-delete-with-data]')!.click(), + ); + expect(mocks.removePlugin).toHaveBeenCalledWith('plugin-a'); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('settings.jsPluginRemoveFailed'); + }); + + it('OBS 종료 후 설정 저장만 실패하면 실제 종료 상태를 유지한다', async () => { + mocks.stopObs.mockResolvedValue({ + running: false, + port: 1234, + clientCount: 0, + token: 'original', + }); + mocks.update.mockRejectedValue(new Error('settings failed')); + const toggle = host.querySelector( + '[data-toggle="settings.obsMode"]', + )!; + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + expect(mocks.stopObs).toHaveBeenCalledOnce(); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('OBS 종료 응답과 뒤따르는 설정 저장까지 종료 정산이 기다린다', async () => { + let finishStop!: (status: unknown) => void; + let finishSettings!: () => void; + mocks.stopObs.mockReturnValue( + new Promise((resolve) => { + finishStop = resolve; + }), + ); + mocks.update.mockReturnValue( + new Promise((resolve) => { + finishSettings = resolve; + }), + ); + act(() => + host + .querySelector('[data-toggle="settings.obsMode"]')! + .click(), + ); + const drain = beginEditorWriteBarrier(); + let settled = false; + const result = drain().then((value) => { + settled = true; + return value; + }); + await act(async () => { + await Promise.resolve(); + }); + const settledBeforeStop = settled; + await act(async () => { + finishStop({ + running: false, + port: 1234, + clientCount: 0, + token: 'original', + }); + }); + const settledBeforeSettings = settled; + await act(async () => { + finishSettings(); + await result; + }); + expect(settledBeforeStop).toBe(false); + expect(settledBeforeSettings).toBe(false); + expect(await result).toBe(true); + expect(mocks.update).toHaveBeenCalledWith({ obsModeEnabled: false }); + }); + + it('OBS 종료 명령 자체가 실패하면 실행 상태를 되돌리고 안내한다', async () => { + mocks.stopObs.mockRejectedValue(new Error('stop failed')); + const drain = beginEditorWriteBarrier(); + const toggle = host.querySelector( + '[data-toggle="settings.obsMode"]', + )!; + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + expect(mocks.update).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('settings.obsStopFailed'); + expect(await drain()).toBe(false); + }); + + it('오버레이 기준점 저장 실패를 되돌리고 안내한다', async () => { + const before = useSettingsStore.getState().overlayResizeAnchor; + mocks.setAnchor.mockRejectedValue(new Error('anchor failed')); + choose( + 'settings.resizeAnchor', + before === 'top-left' ? 'bottom-right' : 'top-left', + ); + await act(async () => { + await Promise.resolve(); + }); + expect(useSettingsStore.getState().overlayResizeAnchor).toBe(before); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('사운드 출력 저장 실패를 안내하고 실제 장치 선택을 다시 읽는다', async () => { + mocks.setSoundBackend.mockRejectedValue(new Error('sound failed')); + const select = Array.from(host.querySelectorAll('select')).find((node) => + Array.from(node.options).some( + (option) => option.textContent === 'Speaker', + ), + )!; + act(() => { + select.value = Array.from(select.options).find( + (option) => option.textContent === 'Speaker', + )!.value; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(select.value).toBe('defaultDevice'); + expect(mocks.getSoundState).toHaveBeenCalledTimes(2); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); + + it('데이터 없는 플러그인은 사전 조회와 뒤따르는 제거까지 정산한다', async () => { + let finishCheck!: (value: boolean) => void; + let finishRemove!: (value: unknown) => void; + mocks.hasPluginData.mockReturnValue( + new Promise((resolve) => { + finishCheck = resolve; + }), + ); + mocks.removePlugin.mockReturnValue( + new Promise((resolve) => { + finishRemove = resolve; + }), + ); + act(() => button('settings.managePlugins').click()); + act(() => + host.querySelector('[data-remove-plugin]')!.click(), + ); + const drain = beginEditorWriteBarrier(); + let settled = false; + const result = drain().then((value) => { + settled = true; + return value; + }); + await act(async () => { + await Promise.resolve(); + }); + const settledBeforeCheck = settled; + await act(async () => { + finishCheck(false); + }); + const settledBeforeRemove = settled; + await act(async () => { + finishRemove({ success: true }); + await result; + }); + expect(settledBeforeCheck).toBe(false); + expect(settledBeforeRemove).toBe(false); + expect(await result).toBe(true); + expect(mocks.removePlugin).toHaveBeenCalledOnce(); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + }); + + it('데이터가 있는 플러그인은 확인창을 열고 정산 대기를 해제한다', async () => { + let finishCheck!: (value: boolean) => void; + mocks.hasPluginData.mockReturnValue( + new Promise((resolve) => { + finishCheck = resolve; + }), + ); + act(() => button('settings.managePlugins').click()); + act(() => + host.querySelector('[data-remove-plugin]')!.click(), + ); + const drain = beginEditorWriteBarrier(); + let settled = false; + const result = drain().then((value) => { + settled = true; + return value; + }); + await act(async () => { + await Promise.resolve(); + }); + const settledBeforeCheck = settled; + await act(async () => { + finishCheck(true); + await result; + }); + expect(settledBeforeCheck).toBe(false); + expect(await result).toBe(true); + expect(host.querySelector('[data-delete-with-data]')).not.toBeNull(); + expect(mocks.removePlugin).not.toHaveBeenCalled(); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + }); + + it('플러그인 사전 조회가 실패하면 삭제하지 않고 정산도 실패한다', async () => { + mocks.hasPluginData.mockRejectedValue(new Error('read failed')); + act(() => button('settings.managePlugins').click()); + const drain = beginEditorWriteBarrier(); + await act(async () => + host.querySelector('[data-remove-plugin]')!.click(), + ); + expect(await drain()).toBe(false); + expect(mocks.removePlugin).not.toHaveBeenCalled(); + expect(mocks.clearPluginData).not.toHaveBeenCalled(); + expect(showAlert).toHaveBeenCalledWith('settings.jsPluginRemoveFailed'); + }); + + it('플러그인 제거 성공 후에만 선택한 저장 데이터를 삭제한다', async () => { + mocks.removePlugin.mockResolvedValue({ success: true }); + act(() => button('settings.managePlugins').click()); + await act(async () => + host.querySelector('[data-remove-plugin]')!.click(), + ); + await act(async () => + host.querySelector('[data-delete-with-data]')!.click(), + ); + expect(mocks.clearPluginData).toHaveBeenCalledWith('plugin-a/'); + expect(mocks.removePlugin.mock.invocationCallOrder[0]).toBeLessThan( + mocks.clearPluginData.mock.invocationCallOrder[0], + ); + }); + + it('언어 저장 응답을 기다리는 동안 중복 선택을 막고 성공 후 반영한다', async () => { + let finish!: () => void; + mocks.changeLanguage.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + choose('settings.language', 'en'); + const select = host.querySelector( + '[data-label="settings.language"] select', + )!; + expect(select.disabled).toBe(true); + expect(useSettingsStore.getState().language).toBe('ko'); + await act(async () => { + finish(); + await Promise.resolve(); + }); + expect(select.disabled).toBe(false); + expect(useSettingsStore.getState().language).toBe('en'); + expect(mocks.changeLanguage).toHaveBeenCalledOnce(); + }); + + it.each([ + 'settings.alwaysOnTop', + 'settings.overlayLock', + 'settings.noteEffect', + 'settings.trayEnabled', + 'settings.autoUpdate', + 'settings.developerMode', + 'settings.keyCounter', + ])('%s 저장 실패를 표시한다', async (label) => { + mocks.update.mockRejectedValue(new Error('write failed')); + mocks.setLock.mockRejectedValue(new Error('write failed')); + const toggle = host.querySelector( + `[data-toggle="${label}"]`, + )!; + expect(toggle).not.toBeNull(); + const before = toggle.getAttribute('aria-pressed'); + await act(async () => toggle.click()); + expect(toggle.getAttribute('aria-pressed')).toBe(before); + expect(showAlert).toHaveBeenCalledWith('common.saveFailed'); + }); +}); diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 89464618b..14bb0b597 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -1,4 +1,6 @@ -import React, { startTransition, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useSingleFlightAction } from '@hooks/useSingleFlightAction'; +import { trackEditorWrite } from '@src/renderer/editor/runtime/editorWriteBarrier'; import { useLenis } from '@hooks/useLenis'; import { useTranslation } from '@contexts/useTranslation'; import { useSettingsStore } from '@stores/useSettingsStore'; @@ -93,7 +95,7 @@ interface SettingsProps { showAlert: (msg: string, confirmText?: string) => void; showConfirm: ( msg: string, - onConfirm: () => void, + onConfirm: () => void | Promise, options?: { onCancel?: () => void; confirmText?: string; @@ -264,6 +266,7 @@ const Settings = ({ if (!pendingKeySoundOutputRef.current) setKeySoundOutput(result); } catch (error) { console.error('Failed to set key sound output backend', error); + showAlert(t('common.saveFailed')); if (!pendingKeySoundOutputRef.current) { try { const authoritative = await keySoundOutputApi.getState(); @@ -417,6 +420,7 @@ const Settings = ({ } catch (error) { setAlwaysOnTop(!next); console.error('Failed to toggle always-on-top', error); + showAlert(t('common.saveFailed')); } }; @@ -428,6 +432,7 @@ const Settings = ({ } catch (error) { setOverlayLocked(!next); console.error('Failed to toggle overlay lock', error); + showAlert(t('common.saveFailed')); } }; @@ -439,6 +444,7 @@ const Settings = ({ } catch (error) { setUseCustomCSS(!next); console.error('Failed to toggle custom CSS', error); + showAlert(t('common.saveFailed')); } }; @@ -450,6 +456,7 @@ const Settings = ({ } catch (error) { setUseCustomJS(!next); console.error('Failed to toggle custom JS', error); + showAlert(t('common.saveFailed')); } }; @@ -630,37 +637,41 @@ const Settings = ({ setPendingPluginId(pluginId); try { - // 실제 플러그인 네임스페이스 추출 (@id 또는 파일명 기반) - const pluginNamespace: string = extractPluginId( - plugin.content, - plugin.name, - ); - const pluginStorageNamespace = `${pluginNamespace}/`; + await trackEditorWrite( + (async () => { + // 실제 플러그인 네임스페이스 추출 (@id 또는 파일명 기반) + const pluginNamespace: string = extractPluginId( + plugin.content, + plugin.name, + ); + const pluginStorageNamespace = `${pluginNamespace}/`; - // 네임스페이스를 prefix로 사용하는 데이터가 있는지 확인 - // 백엔드에서 자동으로 "plugin_data_" 를 붙이므로 순수 네임스페이스만 전달 - const hasData: boolean = await pluginApi.storage.hasData( - pluginStorageNamespace, - ); - console.warn( - '[PluginRemove] namespace=', - pluginNamespace, - 'hasData=', - hasData, - ); + // 네임스페이스를 prefix로 사용하는 데이터가 있는지 확인 + // 백엔드에서 자동으로 "plugin_data_" 를 붙이므로 순수 네임스페이스만 전달 + const hasData: boolean = await pluginApi.storage.hasData( + pluginStorageNamespace, + ); + console.warn( + '[PluginRemove] namespace=', + pluginNamespace, + 'hasData=', + hasData, + ); - if (hasData) { - setPluginToDelete({ - id: pluginId, - name: plugin.name, - namespace: pluginNamespace, - }); - setDataDeleteModalOpen(true); - } else { - removingPluginRef.current = null; - setPendingPluginId(null); - await removePluginOnly(pluginId); - } + if (hasData) { + setPluginToDelete({ + id: pluginId, + name: plugin.name, + namespace: pluginNamespace, + }); + setDataDeleteModalOpen(true); + } else { + removingPluginRef.current = null; + setPendingPluginId(null); + await removePluginOnly(pluginId); + } + })(), + ); } catch (error) { console.error('Failed to check plugin data', error); showAlert?.(t('settings.jsPluginRemoveFailed')); @@ -713,6 +724,7 @@ const Settings = ({ const result: JsRemoveResult = await jsApi.remove(pluginId); if (!result.success) { showAlert?.(t('settings.jsPluginRemoveFailed')); + return; } // 2) 그 다음 스토리지 정리 → 클린업 중 재생성된 값까지 함께 제거 @@ -746,6 +758,7 @@ const Settings = ({ } catch (error) { setNoteEffect(!next); console.error('Failed to toggle note effect', error); + showAlert(t('common.saveFailed')); } }; @@ -770,12 +783,15 @@ const Settings = ({ if (isMacOS || angleModeChangeRef.current) return; angleModeChangeRef.current = true; const apply = async (): Promise => { - setAngleMode(val); + let saved = false; try { await settingsApi.update({ angleMode: val }); + saved = true; + setAngleMode(val); await appApi.restart(); } catch (error) { console.error('Failed to change angle mode', error); + showAlert(t(saved ? 'common.restartFailed' : 'common.saveFailed')); } finally { angleModeChangeRef.current = false; } @@ -807,6 +823,7 @@ const Settings = ({ confirmedResizeAnchorRef.current = requested; } catch (error) { console.error('Failed to set overlay anchor', error); + showAlert(t('common.saveFailed')); if (!pendingResizeAnchorRef.current) { setOverlayResizeAnchor(confirmedResizeAnchorRef.current); } @@ -824,6 +841,7 @@ const Settings = ({ } catch (error) { setTrayEnabled(!next); console.error('Failed to toggle tray mode', error); + showAlert(t('common.saveFailed')); } }; @@ -835,6 +853,7 @@ const Settings = ({ } catch (error) { setAutoUpdateEnabled(!next); console.error('Failed to toggle auto update', error); + showAlert(t('common.saveFailed')); } }; @@ -843,15 +862,27 @@ const Settings = ({ const next = !obsStatus.running; setObsStatus((prev) => ({ ...prev, running: next })); obsTogglingRef.current = true; + let commandCompleted = false; try { - const status = next ? await obsApi.start() : await obsApi.stop(); - setObsStatus(status); - await settingsApi.update({ obsModeEnabled: next }); + await trackEditorWrite( + (async () => { + const status = next ? await obsApi.start() : await obsApi.stop(); + commandCompleted = true; + setObsStatus(status); + await settingsApi.update({ obsModeEnabled: next }); + })(), + ); } catch (error) { console.error('Failed to toggle OBS mode', error); - setObsStatus((prev) => ({ ...prev, running: !next })); + if (!commandCompleted) { + setObsStatus((prev) => ({ ...prev, running: !next })); + } showAlert?.( - next ? t('settings.obsStartFailed') : t('settings.obsStopFailed'), + commandCompleted + ? t('common.saveFailed') + : next + ? t('settings.obsStartFailed') + : t('settings.obsStopFailed'), ); } finally { obsTogglingRef.current = false; @@ -881,6 +912,7 @@ const Settings = ({ setObsStatus(status); } catch (error) { console.error('Failed to regenerate OBS token', error); + showAlert(t('common.actionFailed')); } finally { regeneratingObsTokenRef.current = false; } @@ -908,6 +940,7 @@ const Settings = ({ } catch (error) { setDeveloperModeEnabled(!next); console.error('Failed to toggle developer mode', error); + showAlert(t('common.saveFailed')); } }; @@ -919,6 +952,7 @@ const Settings = ({ } catch (error) { setKeyCounterEnabled(!next); console.error('Failed to toggle key counter', error); + showAlert(t('common.saveFailed')); } }; @@ -967,6 +1001,7 @@ const Settings = ({ } } catch (error) { console.error('Failed to reset presets', error); + showAlert(t('common.actionFailed')); } finally { resetAllRef.current = false; } @@ -984,12 +1019,16 @@ const Settings = ({ } }; - const handleLanguageChange = (val: string): void => { - startTransition(() => { - setLanguage(val); - void i18n.changeLanguage(val as SupportedLocale); + const { run: handleLanguageChange, pending: languagePending } = + useSingleFlightAction(async (val: string) => { + try { + await i18n.changeLanguage(val as SupportedLocale); + setLanguage(val); + } catch (error) { + console.error('Failed to change language', error); + showAlert(t('common.saveFailed')); + } }); - }; const requestedBackend = keySoundOutput?.requested; const requestedAsioDriver = @@ -1274,6 +1313,7 @@ const Settings = ({ options={LANGUAGE_OPTIONS} value={language} onChange={handleLanguageChange} + disabled={languagePending} placeholder={t('settings.selectLanguage')} align="right" /> diff --git a/src/renderer/components/shared/PluginElement.contextMenuFreeze.test.tsx b/src/renderer/components/shared/PluginElement.contextMenuFreeze.test.tsx index dd672dc83..9d06005e8 100644 --- a/src/renderer/components/shared/PluginElement.contextMenuFreeze.test.tsx +++ b/src/renderer/components/shared/PluginElement.contextMenuFreeze.test.tsx @@ -89,7 +89,7 @@ const renderElement = (element: PluginDisplayElementInternal) => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/PluginElement.cursorPolicy.test.tsx b/src/renderer/components/shared/PluginElement.cursorPolicy.test.tsx index e2c35d81e..42b1612de 100644 --- a/src/renderer/components/shared/PluginElement.cursorPolicy.test.tsx +++ b/src/renderer/components/shared/PluginElement.cursorPolicy.test.tsx @@ -64,7 +64,7 @@ const renderElement = (windowType: 'main' | 'overlay') => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/PluginElement.groupClick.test.tsx b/src/renderer/components/shared/PluginElement.groupClick.test.tsx index badb2dad0..3e3df6610 100644 --- a/src/renderer/components/shared/PluginElement.groupClick.test.tsx +++ b/src/renderer/components/shared/PluginElement.groupClick.test.tsx @@ -79,7 +79,7 @@ const renderElement = () => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/PluginElement.legacyHtmlStability.test.tsx b/src/renderer/components/shared/PluginElement.legacyHtmlStability.test.tsx index bd707bcfa..1da249e9d 100644 --- a/src/renderer/components/shared/PluginElement.legacyHtmlStability.test.tsx +++ b/src/renderer/components/shared/PluginElement.legacyHtmlStability.test.tsx @@ -87,7 +87,7 @@ const renderElement = (element: PluginDisplayElementInternal, zoom: number) => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} > , diff --git a/src/renderer/components/shared/PluginElement.mountErrorIsolation.test.tsx b/src/renderer/components/shared/PluginElement.mountErrorIsolation.test.tsx index 7f2e5652d..7fce8bbd5 100644 --- a/src/renderer/components/shared/PluginElement.mountErrorIsolation.test.tsx +++ b/src/renderer/components/shared/PluginElement.mountErrorIsolation.test.tsx @@ -93,7 +93,7 @@ describe('플러그인 onMount 오류 격리', () => { act(() => { root.render( {}, t: (key) => key }} + value={{ locale: 'ko', setLocale: async () => {}, t: (key) => key }} >
healthy
diff --git a/src/renderer/contexts/I18nContext.save.test.tsx b/src/renderer/contexts/I18nContext.save.test.tsx new file mode 100644 index 000000000..fdd703819 --- /dev/null +++ b/src/renderer/contexts/I18nContext.save.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import React, { act, useContext } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from './I18nContext'; +import { I18nContext } from './I18nContextDef'; + +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + result: null as Promise | null, +})); +vi.mock('@api/modules/settingsApi', () => ({ + settingsApi: { update: mocks.update }, +})); + +describe('언어 저장과 표시 일치', () => { + let root: Root; + let host: HTMLDivElement; + const originalApi = window.api; + const Probe = () => { + const context = useContext(I18nContext)!; + return ( + + ); + }; + beforeEach(async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + localStorage.setItem('dmnote:locale', 'ko'); + localStorage.setItem('dmnote:locale_initialized', '1'); + mocks.update.mockReset(); + mocks.result = null; + vi.spyOn(console, 'error').mockImplementation(() => {}); + window.api = { + settings: { + get: async () => ({ language: 'ko' }), + onChanged: () => () => {}, + }, + } as never; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(async () => + root.render( + + + , + ), + ); + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(host.querySelector('button')).not.toBeNull(); + }); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + window.api = originalApi; + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('저장에 실패하면 호출자에게 알리고 화면과 다음 실행 언어를 유지한다', async () => { + const failure = new Error('language write failed'); + mocks.update.mockRejectedValue(failure); + await act(async () => + host.querySelector('button')!.click(), + ); + await expect(mocks.result).rejects.toBe(failure); + expect( + host.querySelector('[data-locale]')?.getAttribute('data-locale'), + ).toBe('ko'); + expect(localStorage.getItem('dmnote:locale')).toBe('ko'); + }); + + it('저장 응답을 받은 뒤 화면과 다음 실행 언어를 함께 바꾼다', async () => { + let complete!: () => void; + mocks.update.mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ); + act(() => host.querySelector('button')!.click()); + expect( + host.querySelector('[data-locale]')?.getAttribute('data-locale'), + ).toBe('ko'); + expect(localStorage.getItem('dmnote:locale')).toBe('ko'); + await act(async () => { + complete(); + await mocks.result; + }); + expect( + host.querySelector('[data-locale]')?.getAttribute('data-locale'), + ).toBe('en'); + expect(localStorage.getItem('dmnote:locale')).toBe('en'); + }); +}); diff --git a/src/renderer/contexts/I18nContext.tsx b/src/renderer/contexts/I18nContext.tsx index 2cd15e8f4..b8fc5b3a6 100644 --- a/src/renderer/contexts/I18nContext.tsx +++ b/src/renderer/contexts/I18nContext.tsx @@ -213,12 +213,10 @@ export function I18nProvider({ children }: { children: ReactNode }) { }; }, []); - const changeLocale = (next: SupportedLocale) => { + const changeLocale = async (next: SupportedLocale): Promise => { + await settingsApi.update({ language: next }); setLocaleState(next); safeLocalStorageSet(STORAGE_KEY, next); - settingsApi.update({ language: next }).catch((error) => { - console.error('Failed to update language', error); - }); }; const t = function translate( diff --git a/src/renderer/contexts/I18nContextDef.ts b/src/renderer/contexts/I18nContextDef.ts index 5afff2c5e..769a9c13a 100644 --- a/src/renderer/contexts/I18nContextDef.ts +++ b/src/renderer/contexts/I18nContextDef.ts @@ -4,7 +4,7 @@ export type SupportedLocale = 'ko' | 'en' | 'zh-cn' | 'zh-Hant' | 'ru'; export interface I18nContextValue { locale: SupportedLocale; - setLocale: (locale: SupportedLocale) => void; + setLocale: (locale: SupportedLocale) => Promise; t: (key: string, params?: Record) => string; } diff --git a/src/renderer/windows/main/App.confirm.test.tsx b/src/renderer/windows/main/App.confirm.test.tsx new file mode 100644 index 000000000..828ca9725 --- /dev/null +++ b/src/renderer/windows/main/App.confirm.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + resetCountersMode: vi.fn(), + confirm: null as Promise | null, + checkForUpdates: vi.fn(), +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@hooks/app/useCustomCssInjection', () => ({ + useCustomCssInjection: vi.fn(), +})); +vi.mock('@hooks/app/useCustomJsInjection', () => ({ + useCustomJsInjection: vi.fn(), +})); +vi.mock('@hooks/app/useAppBootstrap', () => ({ useAppBootstrap: vi.fn() })); +vi.mock('@hooks/app/usePluginDisplayElementsResponder', () => ({ + usePluginDisplayElementsResponder: vi.fn(), +})); +vi.mock('@hooks/app/useBlockBrowserShortcuts', () => ({ + useBlockBrowserShortcuts: vi.fn(), +})); +vi.mock('@hooks/panel/usePanelCloseRequest', () => ({ + usePanelCloseRequest: vi.fn(), +})); +vi.mock('@hooks/app/useUpdateCheck', () => ({ + useUpdateCheck: () => ({ checkForUpdates: mocks.checkForUpdates }), + hasPendingPostUpdateReleaseNotice: () => false, + clearPendingPostUpdateReleaseNotice: vi.fn(), + UpdateInstalledRestartFailedError: class extends Error {}, +})); +vi.mock('@hooks/useKeyManager', () => ({ + useKeyManager: () => ({ + keyMappings: {}, + positions: {}, + handleResetCurrentMode: vi.fn(), + }), +})); +vi.mock('@hooks/Modal/usePalette', () => ({ + usePalette: () => ({ + palette: false, + color: 'transparent', + setPalette: vi.fn(), + }), +})); +vi.mock('@api/modules/keysApi', () => ({ + keysApi: { resetCountersMode: mocks.resetCountersMode }, +})); +vi.mock('@components/main/TitleBar', () => ({ default: () => null })); +vi.mock('@components/main/EditorSaveNotice', () => ({ default: () => null })); +vi.mock('@components/main/Grid', () => ({ default: () => null })); +vi.mock('@components/main/Settings', () => ({ default: () => null })); +vi.mock('@components/main/Grid/PropertiesPanelHost', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/PopupExit', () => ({ default: () => null })); +vi.mock('@components/main/Modal/FloatingPopup', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/dialogs/UpdateModal', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/settings/NoteSetting', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/pickers/Palette', () => ({ + default: () => null, +})); +vi.mock('@components/main/Modal/content/pickers/ColorPicker', () => ({ + default: () => null, +})); +vi.mock('@components/main/Tool/ToolBar', () => ({ + default: ({ onResetCounters }: { onResetCounters: () => void }) => ( + + ), +})); +vi.mock('@components/main/Modal/content/dialogs/Alert', () => ({ + default: ({ + isOpen, + message, + onConfirm, + }: { + isOpen: boolean; + message: string; + onConfirm: () => void | Promise; + }) => + isOpen ? ( +
+ {message} + +
+ ) : null, +})); + +import App from './App'; + +describe('App 비동기 확인 동작', () => { + let root: Root; + let host: HTMLDivElement; + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + mocks.resetCountersMode.mockReset(); + mocks.confirm = null; + vi.spyOn(console, 'error').mockImplementation(() => {}); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + act(() => root.render()); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + }); + const confirmReset = () => { + act(() => + host.querySelector('[data-reset-counters]')!.click(), + ); + act(() => host.querySelector('[data-confirm]')!.click()); + }; + + it('카운터 초기화 확인은 저장 응답을 받은 뒤 완료된다', async () => { + let finish!: () => void; + mocks.resetCountersMode.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + confirmReset(); + let completed = false; + void mocks.confirm!.then(() => { + completed = true; + }); + await act(async () => { + await Promise.resolve(); + }); + const completedBeforeWrite = completed; + await act(async () => { + finish(); + await mocks.confirm; + }); + expect(completedBeforeWrite).toBe(false); + expect(mocks.resetCountersMode).toHaveBeenCalledOnce(); + }); + + it('카운터 초기화 실패는 처리되지 않은 예외 대신 사용자에게 안내한다', async () => { + mocks.resetCountersMode.mockRejectedValue( + new Error('counter reset failed'), + ); + confirmReset(); + await act(async () => { + await mocks.confirm; + }); + expect(host.querySelector('[data-alert]')?.textContent).toContain( + 'common.actionFailed', + ); + expect(mocks.resetCountersMode).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index 15bf79b82..a65c446fc 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -235,7 +235,7 @@ export default function App() { }, [developerModeEnabled]); const { t } = useTranslation(); - const confirmCallbackRef = useRef<(() => void) | null>(null); + const confirmCallbackRef = useRef<(() => void | Promise) | null>(null); const cancelCallbackRef = useRef<(() => void) | null>(null); const [alertState, setAlertState] = useState(() => ({ isOpen: false, @@ -444,7 +444,7 @@ export default function App() { const showConfirm = ( message: string, - onConfirm: () => void, + onConfirm: () => void | Promise, options?: { onCancel?: () => void; confirmText?: string; @@ -480,11 +480,16 @@ export default function App() { cancelCallbackRef.current = null; }; - // 닫은 뒤 콜백 실행 — 콜백이 동기적으로 새 다이얼로그를 열어도 닫히지 않게 - const handleAlertConfirm = () => { + // 닫은 뒤 콜백 실행, 콜백이 연 새 다이얼로그는 유지 + const handleAlertConfirm = async () => { const callback = confirmCallbackRef.current; closeAlert(); - callback?.(); + try { + await callback?.(); + } catch (error) { + console.error('Failed to complete confirmed action', error); + showAlert(t('common.actionFailed')); + } }; // 노트 설정 모달 수명 - 퇴장 모션이 도는 동안 마운트를 유지한다. From 7f575b9afd3c66a8c4cb82510aabaa102b2bfa56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 16:24:39 +0900 Subject: [PATCH 116/125] =?UTF-8?q?feat:=20=EB=AF=B8=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=95=88=EB=82=B4=EC=99=80=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/main/EditorSaveNotice.test.tsx | 173 ++++++++++++++++++ .../components/main/EditorSaveNotice.tsx | 56 ++++++ ...seAppBootstrap.modeSelectionReset.test.tsx | 151 ++++++++++++++- src/renderer/hooks/app/useAppBootstrap.ts | 60 ++++-- src/renderer/windows/main/App.tsx | 2 + 5 files changed, 418 insertions(+), 24 deletions(-) create mode 100644 src/renderer/components/main/EditorSaveNotice.test.tsx create mode 100644 src/renderer/components/main/EditorSaveNotice.tsx diff --git a/src/renderer/components/main/EditorSaveNotice.test.tsx b/src/renderer/components/main/EditorSaveNotice.test.tsx new file mode 100644 index 000000000..b8ab19de6 --- /dev/null +++ b/src/renderer/components/main/EditorSaveNotice.test.tsx @@ -0,0 +1,173 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createEditorCoordinator } from '@src/renderer/editor/runtime/editorCoordinator'; +import { createDefaultKeyPosition } from '@src/renderer/editor/model/keys'; +import type { + CanonicalEditorDocumentV1, + EditorCommitResult, +} from '@src/types/editor'; +import EditorSaveNotice from './EditorSaveNotice'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + coordinator: null as ReturnType | null, + settle: vi.fn(), + commit: vi.fn(), +})); +vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ + editorCoordinator: { + getState: () => mocks.coordinator!.getState(), + subscribe: ( + listener: Parameters< + ReturnType['subscribe'] + >[0], + ) => mocks.coordinator!.subscribe(listener), + flush: () => mocks.coordinator!.flush(), + }, +})); +vi.mock('@src/renderer/editor/runtime/lifecycleEditorFlush', () => ({ + flushFocusedEditor: mocks.settle, +})); +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('@stores/data/useHistoryStatusStore', () => ({ + useHistoryStatusStore: (selector: (state: { busy: boolean }) => unknown) => + selector({ busy: false }), +})); + +describe('미저장 편집 안내와 실제 coordinator 재시도', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(async () => { + mocks.commit.mockReset(); + mocks.settle.mockReset().mockResolvedValue(true); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let document: CanonicalEditorDocumentV1 = { + schemaVersion: 1, + keys: { '4key': ['A'] }, + keyPositions: { + '4key': [ + { + ...createDefaultKeyPosition(), + id: '11111111-1111-4111-8111-111111111111', + }, + ], + }, + statPositions: {}, + graphPositions: {}, + knobPositions: {}, + spritePositions: {}, + layerGroups: {}, + }; + mocks.coordinator = createEditorCoordinator({ + transport: { + get: async () => ({ revision: 0, document: structuredClone(document) }), + commit: mocks.commit, + onCommitted: () => + Object.assign(() => {}, { ready: Promise.resolve() }), + }, + readDocument: () => document, + applyDocument: (next) => { + document = next; + }, + focusTarget: null, + visibilityTarget: null, + }); + await mocks.coordinator.start(); + container = window.document.createElement('div'); + window.document.body.append(container); + root = createRoot(container); + act(() => root.render()); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + mocks.coordinator!.stop(); + vi.restoreAllMocks(); + }); + + const failEdit = async (errorCode = 'IO_ERROR') => { + mocks.commit.mockRejectedValue({ + errorCode, + message: 'save failed', + retryable: true, + }); + await act(async () => { + await mocks + .coordinator!.commitPatch({ schemaVersion: 1, keys: { '4key': ['B'] } }) + .catch(() => {}); + }); + }; + + it.each(['HISTORY_IN_PROGRESS', 'HISTORY_EPOCH_CONFLICT', 'IO_ERROR'])( + '%s 이후 초안을 유지하고 명시적 재시도 경로를 표시한다', + async (code) => { + await failEdit(code); + expect(mocks.coordinator!.getState().dirty).toBe(true); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + 'editorSave.pendingFailure', + ); + expect(container.querySelector('button')?.textContent).toBe( + 'editorSave.retrySave', + ); + expect(mocks.commit).toHaveBeenCalledOnce(); + }, + ); + + it('저장 성공 응답까지 안내를 유지하고 중복 클릭은 한 번만 저장한다', async () => { + await failEdit(); + let resolve!: (result: EditorCommitResult) => void; + mocks.commit.mockImplementation( + () => + new Promise((yes) => { + resolve = yes; + }), + ); + await act(async () => { + container.querySelector('button')!.click(); + container.querySelector('button')!.click(); + await new Promise((yes) => setTimeout(yes, 0)); + }); + expect(mocks.commit).toHaveBeenCalledTimes(2); + expect(container.querySelector('button')?.disabled).toBe(true); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + await act(async () => { + resolve({ revision: 1, changedFields: ['keys'] }); + await new Promise((yes) => setTimeout(yes, 0)); + }); + expect(mocks.coordinator!.getState().dirty).toBe(false); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); + + it('재시도도 실패하면 안내와 초안을 유지한다', async () => { + await failEdit(); + await act(async () => { + container.querySelector('button')!.click(); + await new Promise((yes) => setTimeout(yes, 0)); + }); + expect(mocks.commit).toHaveBeenCalledTimes(2); + expect(mocks.coordinator!.getState().pendingLocal?.keys['4key']).toEqual([ + 'B', + ]); + expect(container.querySelector('button')?.disabled).toBe(false); + }); + + it('다른 편집 정산이 실패하면 저장을 진행하지 않는다', async () => { + await failEdit(); + mocks.settle.mockResolvedValue(false); + await act(async () => { + container.querySelector('button')!.click(); + }); + expect(mocks.commit).toHaveBeenCalledOnce(); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + }); + + it('저장 실패가 없으면 안내를 표시하지 않는다', () => { + expect(container.querySelector('[role="status"]')).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/EditorSaveNotice.tsx b/src/renderer/components/main/EditorSaveNotice.tsx new file mode 100644 index 000000000..b3f4827d8 --- /dev/null +++ b/src/renderer/components/main/EditorSaveNotice.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from '@contexts/useTranslation'; +import { useSingleFlightAction } from '@hooks/useSingleFlightAction'; +import { useHistoryStatusStore } from '@stores/data/useHistoryStatusStore'; +import { editorCoordinator } from '@src/renderer/editor/runtime/editorStateCoordinator'; +import { flushFocusedEditor } from '@src/renderer/editor/runtime/lifecycleEditorFlush'; +import type { EditorCoordinatorState } from '@src/renderer/editor/runtime/editorCoordinator'; + +const EditorSaveNotice = () => { + const { t } = useTranslation(); + const [needsSave, setNeedsSave] = useState(false); + const [saving, setSaving] = useState(false); + const historyBusy = useHistoryStatusStore((state) => state.busy); + + useEffect(() => { + const update = (state: EditorCoordinatorState) => { + if (!state.dirty) setNeedsSave(false); + else if (state.failureKind === 'transient') setNeedsSave(true); + setSaving(state.phase === 'saving'); + }; + const unsubscribe = editorCoordinator.subscribe(update); + update(editorCoordinator.getState()); + return unsubscribe; + }, []); + + const { run: retry, pending } = useSingleFlightAction(async () => { + try { + if (!(await flushFocusedEditor())) return; + await editorCoordinator.flush(); + } catch (error) { + console.error('편집 내용 다시 저장 실패', error); + } + }); + + if (!needsSave) return null; + const busy = pending || saving || historyBusy; + + return ( +
+ {t('editorSave.pendingFailure')} + +
+ ); +}; + +export default EditorSaveNotice; diff --git a/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx b/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx index 65ad7f117..c141cdab2 100644 --- a/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx +++ b/src/renderer/hooks/app/useAppBootstrap.modeSelectionReset.test.tsx @@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useSettingsStore } from '@stores/useSettingsStore'; import type { BootstrapPayload } from '@src/types/app'; import type { EditorCoordinatorState } from '@src/renderer/editor/runtime/editorCoordinator'; +import { + isHistoryEditorFlushLocked, + subscribeHistoryEditorFlushStart, +} from '@src/renderer/editor/runtime/historyEditorFlushLock'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -27,8 +31,17 @@ const mocks = vi.hoisted(() => ({ resyncListener: null as null | (() => void), bootstrap: vi.fn(), syncHistoryStatus: vi.fn(), - dialogAlert: vi.fn(() => Promise.resolve()), + dialogAlert: vi.fn<(message: string, options?: unknown) => Promise>( + () => Promise.resolve(), + ), editorStateListener: null as null | ((state: EditorCoordinatorState) => void), + closeListener: null as + | null + | ((payload: { handshakeId: string; action: 'history' | 'quit' }) => void), + releaseListener: null as null | ((payload: { handshakeId: string }) => void), + settle: vi.fn(), + ack: vi.fn(), + cancel: vi.fn(), editorState: { conflict: null, failureKind: null, @@ -116,12 +129,16 @@ vi.mock('@api/modules/shared', () => ({ if (event === 'keys:counters-state') { mocks.counterStateListener = listener; } + if (event === 'app:close-requested') mocks.closeListener = listener; + if (event === 'app:history-flush-released') + mocks.releaseListener = listener; return vi.fn(); }), })); vi.mock('@api/modules/appApi', () => ({ - acknowledgeLifecycleAfterEditorFlush: vi.fn(), - cancelLifecycleEditorFlush: vi.fn(), + acknowledgeLifecycleAfterEditorFlush: mocks.ack, + cancelLifecycleEditorFlush: mocks.cancel, + windowApi: { showMain: vi.fn(async () => {}) }, })); vi.mock('@src/renderer/editor/runtime/editorStateCoordinator', () => ({ editorCoordinator: { @@ -165,12 +182,7 @@ vi.mock('@stores/data/useHistoryStatusStore', () => ({ syncHistoryStatus: mocks.syncHistoryStatus, })); vi.mock('@src/renderer/editor/runtime/lifecycleEditorFlush', () => ({ - flushFocusedEditor: vi.fn(), -})); -vi.mock('@src/renderer/editor/runtime/historyEditorFlushLock', () => ({ - acquireHistoryEditorFlushLock: vi.fn(), - releaseHistoryEditorFlushLock: vi.fn(), - resetHistoryEditorFlushLock: vi.fn(), + flushFocusedEditor: mocks.settle, })); vi.mock('@src/renderer/defaults', async (importOriginal) => ({ ...(await importOriginal()), @@ -290,7 +302,11 @@ const makeApiMock = () => }), }, ui: { dialog: { alert: mocks.dialogAlert } }, - overlay: { onLock: vi.fn(() => vi.fn()), onAnchor: vi.fn(() => vi.fn()) }, + overlay: { + get: vi.fn(async () => ({ visible: false })), + onLock: vi.fn(() => vi.fn()), + onAnchor: vi.fn(() => vi.fn()), + }, css: { onUse: vi.fn(() => vi.fn()), onContent: vi.fn(() => vi.fn()) }, js: { onUse: vi.fn(() => vi.fn()), onState: vi.fn(() => vi.fn()) }, } as unknown as Window['api']); @@ -332,6 +348,11 @@ describe('모드 전환 선택 리셋', () => { mocks.presetSnapshotListener = null; mocks.dialogAlert.mockClear(); mocks.editorStateListener = null; + mocks.closeListener = null; + mocks.releaseListener = null; + mocks.settle.mockReset().mockResolvedValue(true); + mocks.ack.mockReset().mockResolvedValue(undefined); + mocks.cancel.mockReset().mockResolvedValue(undefined); mocks.editorState = { conflict: null, failureKind: null, @@ -369,6 +390,116 @@ describe('모드 전환 선택 리셋', () => { expect(mocks.keyState.selectedKeyType).toBe('8key'); }); + it.each(['history', 'quit'] as const)( + '%s 정산 실패를 취소하고 사용자에게 알린다', + async (action) => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.settle.mockResolvedValue(false); + await act(async () => { + mocks.closeListener?.({ handshakeId: 'failed-save', action }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(mocks.cancel).toHaveBeenCalledWith('failed-save'); + expect(mocks.ack).not.toHaveBeenCalled(); + expect(mocks.dialogAlert).toHaveBeenCalledOnce(); + expect(mocks.dialogAlert.mock.calls[0][0]).toContain('저장에 실패해'); + }, + ); + + it('취소 리스너 예외는 즉시 취소 응답으로 연결하고 해제 후 다시 실행할 수 있다', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new Error('gesture cancellation failed'); + const cancelNext = vi.fn(); + const unsubscribeFailure = subscribeHistoryEditorFlushStart(() => { + throw failure; + }); + const unsubscribeNext = subscribeHistoryEditorFlushStart(cancelNext); + let finishCancel!: () => void; + mocks.cancel.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCancel = resolve; + }), + ); + try { + await act(async () => { + expect(() => + mocks.closeListener?.({ + handshakeId: 'failed-cancellation', + action: 'history', + }), + ).not.toThrow(); + expect(document.documentElement.inert).toBe(true); + await Promise.resolve(); + }); + expect(cancelNext).toHaveBeenCalledOnce(); + expect(mocks.cancel).toHaveBeenCalledExactlyOnceWith( + 'failed-cancellation', + ); + expect(mocks.settle).not.toHaveBeenCalled(); + expect(mocks.ack).not.toHaveBeenCalled(); + expect(mocks.dialogAlert).not.toHaveBeenCalled(); + await act(async () => { + mocks.releaseListener?.({ handshakeId: 'failed-cancellation' }); + finishCancel(); + }); + expect(isHistoryEditorFlushLocked()).toBe(false); + expect(document.documentElement.inert).toBe(false); + expect(mocks.dialogAlert).toHaveBeenCalledOnce(); + expect(mocks.dialogAlert.mock.calls[0][0]).toContain('실행 취소'); + unsubscribeFailure(); + mocks.settle.mockImplementationOnce(async () => { + expect(document.documentElement.inert).toBe(true); + return true; + }); + await act(async () => { + mocks.closeListener?.({ + handshakeId: 'next-history', + action: 'history', + }); + }); + expect(mocks.settle).toHaveBeenCalledOnce(); + expect(mocks.ack).toHaveBeenCalledExactlyOnceWith('next-history'); + mocks.releaseListener?.({ handshakeId: 'next-history' }); + expect(isHistoryEditorFlushLocked()).toBe(false); + } finally { + unsubscribeFailure(); + unsubscribeNext(); + } + }); + + it('재시도할 초안이 없는 transient 오류도 한 번 안내한다', () => { + const state = { + ...mocks.editorState, + phase: 'error', + dirty: false, + failureKind: 'transient', + error: { errorCode: 'IO_ERROR', message: 'failed', retryable: true }, + } as EditorCoordinatorState; + act(() => { + mocks.editorStateListener?.(state); + mocks.editorStateListener?.(state); + }); + expect(mocks.dialogAlert).toHaveBeenCalledOnce(); + expect(mocks.dialogAlert.mock.calls[0][0]).toContain( + '편집 저장 중 문제가 생겼습니다', + ); + }); + + it('보존된 transient 초안은 상태 안내에 맡기고 대화상자를 중복 표시하지 않는다', () => { + const state = { + ...mocks.editorState, + phase: 'error', + dirty: true, + failureKind: 'transient', + error: { errorCode: 'IO_ERROR', message: 'failed', retryable: true }, + } as EditorCoordinatorState; + act(() => { + mocks.editorStateListener?.(state); + }); + expect(mocks.dialogAlert).not.toHaveBeenCalled(); + }); + it('customTabs만 바뀌고 모드가 같으면 선택을 건드리지 않는다', () => { act(() => { mocks.customTabsListener?.({ diff --git a/src/renderer/hooks/app/useAppBootstrap.ts b/src/renderer/hooks/app/useAppBootstrap.ts index f976b6c8a..ddaadbb52 100644 --- a/src/renderer/hooks/app/useAppBootstrap.ts +++ b/src/renderer/hooks/app/useAppBootstrap.ts @@ -180,7 +180,7 @@ export function useAppBootstrap() { }; let conflictDialogOpen = false; - let lastShownPermanentEditorError: unknown = null; + let lastShownEditorError: unknown = null; // 키 표시 딜레이와 동기화를 위한 카운터 업데이트 지연 type CounterDelayTimerHandle = ReturnType; interface DelayedCounterUpdate { @@ -419,22 +419,31 @@ export function useAppBootstrap() { }; const handleEditorFailure = (state: EditorCoordinatorState) => { + const transient = state.failureKind === 'transient'; if ( disposed || isOverlayWindow || - state.failureKind !== 'permanent' || + (state.failureKind !== 'permanent' && (!transient || state.dirty)) || !state.error || - state.error === lastShownPermanentEditorError + state.error === lastShownEditorError ) { return; } - lastShownPermanentEditorError = state.error; + lastShownEditorError = state.error; console.error( - '저장할 수 없는 편집 내용을 마지막 저장 상태로 되돌렸습니다', + transient + ? '편집 저장 중 문제가 생겼습니다' + : '저장할 수 없는 편집 내용을 마지막 저장 상태로 되돌렸습니다', state.error, ); - const message = isEditorCapacityFailure(state.error) + const message = transient + ? getEditorCopy( + 'editorSave.transientFailure', + '편집 저장 중 문제가 생겼습니다.\n현재 값을 확인하고 다시 시도해 주세요.', + 'There was a problem saving your edit.\nCheck the current value and try again.', + ) + : isEditorCapacityFailure(state.error) ? getEditorCopy( 'editorSave.capacityFailure', '저장 한도를 넘어 변경을 되돌렸습니다.\n일부 요소를 줄이고 다시 시도해 주세요.', @@ -857,10 +866,10 @@ export function useAppBootstrap() { action: 'quit' | 'restart' | 'history'; }>('app:close-requested', ({ handshakeId, action }) => { if (disposed) return; - if (action === 'history') { - acquireHistoryEditorFlushLock(handshakeId); - } void (async () => { + if (action === 'history') { + acquireHistoryEditorFlushLock(handshakeId); + } const committed = await flushFocusedEditor(); if (!committed) { throw new Error('pending focused editor failed to commit'); @@ -872,11 +881,34 @@ export function useAppBootstrap() { await cancelLifecycleEditorFlush(handshakeId).catch( () => undefined, ); - if (action === 'history') return; - const overlay = await window.api.overlay.get().catch(() => null); - await windowApi.showMain(); - if (overlay?.visible) { - await overlayApi.setVisible(true); + if (action !== 'history') { + const overlay = await window.api.overlay.get().catch(() => null); + await windowApi.showMain(); + if (overlay?.visible) { + await overlayApi.setVisible(true); + } + } + const state = editorCoordinator.getState(); + if ( + !disposed && + !isOverlayWindow && + (!state.error || state.error !== lastShownEditorError) && + !state.conflict + ) { + await window.api.ui.dialog.alert( + action === 'history' + ? getEditorCopy( + 'editorSave.historyCancelled', + '저장에 실패해 실행 취소·다시 실행을 중단했습니다.\n변경 내용을 확인하고 다시 시도해 주세요.', + "Couldn't save your changes, so undo or redo was canceled.\nCheck your changes and try again.", + ) + : getEditorCopy( + 'editorSave.lifecycleCancelled', + '저장에 실패해 종료·재시작을 중단했습니다.\n변경 내용을 확인하고 다시 시도해 주세요.', + "Couldn't save your changes, so quitting or restarting was canceled.\nCheck your changes and try again.", + ), + { confirmText: getEditorCopy('common.ok', '확인', 'OK') }, + ); } })().catch((showError) => { console.error('종료 취소 후 창 복원 실패', showError); diff --git a/src/renderer/windows/main/App.tsx b/src/renderer/windows/main/App.tsx index a65c446fc..49721859b 100644 --- a/src/renderer/windows/main/App.tsx +++ b/src/renderer/windows/main/App.tsx @@ -5,6 +5,7 @@ import { } from './customDialogCallbacks'; import { useTranslation } from '@contexts/useTranslation'; import TitleBar from '@components/main/TitleBar'; +import EditorSaveNotice from '@components/main/EditorSaveNotice'; import { useCustomCssInjection } from '@hooks/app/useCustomCssInjection'; import { USER_CSS_SCOPE_SELECTOR } from '@utils/css/scopeUserCss'; import { useCustomJsInjection } from '@hooks/app/useCustomJsInjection'; @@ -717,6 +718,7 @@ export default function App() { return (
+
{isSettingsOpen ? (
From 82b0e5d7439252b889b164ba6878459a58b97b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sat, 5 Sep 2026 23:13:04 +0900 Subject: [PATCH 117/125] =?UTF-8?q?fix:=20=ED=8C=8C=EC=9D=BC=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=82=B4=EC=9E=A5=20CSS=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EC=99=80=20=EC=A0=9C=EA=B1=B0=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 경로 없이 CSS 원문만 남아도 현재 CSS 카드에서 확인하고 지울 수 있게 한다. 제거는 원문만 비워 마스터 토글 상태를 유지한다. 저장 중 중복 동작을 막고 실패 시 상태를 보존하며 재시도 안내를 표시한다. 다섯 언어의 안내 문장을 줄바꿈한다. CSS 패널과 관련 저장·안내 테스트 86건, 타입·린트·포맷 검증을 통과했다. 격리 앱에서 제거·실패·재시작을 실기 검증한 코드를 유지한다. --- src/renderer/components/main/Settings.tsx | 2 + .../SettingsPanel/CssPanelContent.test.tsx | 191 ++++++++++++++++++ .../main/SettingsPanel/CssPanelContent.tsx | 91 +++++++-- .../main/SettingsPanel/panelChrome.ts | 8 + src/renderer/locales/en.json | 6 +- src/renderer/locales/ko.json | 6 +- src/renderer/locales/ru.json | 6 +- src/renderer/locales/zh-Hant.json | 6 +- src/renderer/locales/zh-cn.json | 6 +- 9 files changed, 299 insertions(+), 23 deletions(-) create mode 100644 src/renderer/components/main/SettingsPanel/CssPanelContent.test.tsx diff --git a/src/renderer/components/main/Settings.tsx b/src/renderer/components/main/Settings.tsx index 14bb0b597..fbc14e24b 100644 --- a/src/renderer/components/main/Settings.tsx +++ b/src/renderer/components/main/Settings.tsx @@ -142,6 +142,7 @@ const Settings = ({ useCustomCSS, setUseCustomCSS, customCSSPath, + customCSSContent, useCustomJS, setUseCustomJS, jsPlugins, @@ -1429,6 +1430,7 @@ const Settings = ({ showAlert?.(msg)} onClose={() => setActiveSettingsPanel(null)} diff --git a/src/renderer/components/main/SettingsPanel/CssPanelContent.test.tsx b/src/renderer/components/main/SettingsPanel/CssPanelContent.test.tsx new file mode 100644 index 000000000..4f024bb16 --- /dev/null +++ b/src/renderer/components/main/SettingsPanel/CssPanelContent.test.tsx @@ -0,0 +1,191 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CustomCssHistoryItem } from '@src/types/plugin/api'; + +const mocks = vi.hoisted(() => ({ + setContent: vi.fn(), + historyGet: vi.fn(), + historyActivate: vi.fn(), + load: vi.fn(), + t: (key: string) => key, +})); + +vi.mock('@contexts/useTranslation', () => ({ + useTranslation: () => ({ t: mocks.t }), +})); +vi.mock('@hooks/useLenis', () => ({ + useLenis: () => ({ scrollContainerRef: null }), +})); +vi.mock('@api/modules/cssApi', () => ({ cssApi: mocks })); + +import CssPanelContent from './CssPanelContent'; + +const deferred = () => { + let resolve!: (value: { success: boolean; error?: string }) => void; + let reject!: (error: Error) => void; + const promise = new Promise<{ success: boolean; error?: string }>( + (res, rej) => { + resolve = res; + reject = rej; + }, + ); + return { promise, resolve, reject }; +}; + +describe('내장 CSS 토큰 칩과 제거', () => { + let root: Root; + let host: HTMLDivElement; + let props: React.ComponentProps; + const showAlert = vi.fn(); + const onHistoryCountChange = vi.fn(); + const file: CustomCssHistoryItem = { + path: '/themes/keys.css', + status: 'available', + lastUsedAt: 1, + }; + const button = (key: string) => + Array.from(host.querySelectorAll('button')).find( + (node) => node.textContent === key, + ); + const removeButton = () => + host.querySelector( + 'button[aria-label="settings.cssEmbeddedRemove"]', + ); + const render = async ( + patch: Partial> = {}, + ) => { + props = { ...props, ...patch }; + await act(async () => root.render()); + }; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.setContent.mockReset().mockResolvedValue({ success: true }); + mocks.historyGet.mockReset().mockResolvedValue([]); + mocks.historyActivate.mockReset(); + mocks.load.mockReset(); + vi.stubGlobal('api', { css: { historyGet: mocks.historyGet } }); + showAlert.mockReset(); + onHistoryCountChange.mockReset(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + props = { + useCustomCSS: true, + customCSSPath: null, + customCSSContent: '[data-key-element] { opacity: 0; }', + onToggleCustomCSS: vi.fn(), + onClose: vi.fn(), + showAlert, + onHistoryCountChange, + }; + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('파일 없는 원문은 상태 카드 칩으로 보이고 목록·개수에는 들어가지 않는다', async () => { + await render(); + expect(host.textContent).toContain('settings.cssEmbedded'); + expect(host.textContent).not.toContain('settings.noCssFile'); + expect(host.textContent).toContain('settings.cssHistoryEmpty'); + expect(removeButton()).not.toBeNull(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(0); + }); + + it('비활성화해도 칩과 제거 버튼을 유지하고 별도 문구는 붙이지 않는다', async () => { + await render({ useCustomCSS: false }); + expect(host.textContent).toContain('settings.cssEmbedded'); + expect(host.textContent).not.toContain('settings.cssApplied'); + expect(removeButton()?.disabled).toBe(false); + }); + + it('원문도 없는 경우 기존 빈 상태를 유지한다', async () => { + await render({ customCSSContent: '' }); + expect(host.textContent).toContain('settings.noCssFile'); + expect(host.textContent).toContain('settings.cssHistoryEmpty'); + expect(removeButton()).toBeNull(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(0); + }); + + it('파일 CSS를 내장 CSS로 중복 표시하지 않는다', async () => { + mocks.historyGet.mockResolvedValue([file]); + await render({ customCSSPath: file.path }); + expect(host.textContent).toContain('keys.css'); + expect(host.textContent).not.toContain('settings.cssEmbedded'); + expect(removeButton()).toBeNull(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(1); + }); + + it('개수는 파일 이력만 세고 원문 유무에 흔들리지 않는다', async () => { + mocks.historyGet.mockResolvedValue([file]); + await render(); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(1); + await render({ customCSSContent: '', useCustomCSS: false }); + expect(onHistoryCountChange).toHaveBeenLastCalledWith(1); + expect(host.textContent).toContain('keys.css'); + expect(removeButton()).toBeNull(); + }); + + it('제거는 원문만 비우고 한 번만 저장하며 완료 전에는 CSS를 교체하지 못한다', async () => { + const pending = deferred(); + mocks.setContent.mockReturnValue(pending.promise); + mocks.historyGet.mockResolvedValue([file]); + await render(); + act(() => { + removeButton()!.click(); + removeButton()!.click(); + }); + expect(mocks.setContent).toHaveBeenCalledTimes(1); + expect(mocks.setContent).toHaveBeenCalledWith(''); + expect(removeButton()?.disabled).toBe(true); + expect(button('settings.loadCss')?.disabled).toBe(true); + expect(button('settings.cssApply')?.disabled).toBe(true); + expect(host.textContent).toContain('settings.cssEmbedded'); + await act(async () => pending.resolve({ success: true })); + await render({ customCSSContent: '' }); + expect(removeButton()).toBeNull(); + expect(host.textContent).toContain('settings.noCssFile'); + expect(showAlert).not.toHaveBeenCalled(); + }); + + it('제거 실패 시 칩을 보존하고 알린 뒤 재시도할 수 있다', async () => { + mocks.setContent.mockRejectedValueOnce(new Error('IO_ERROR')); + await render(); + await act(async () => removeButton()!.click()); + expect(showAlert).toHaveBeenCalledWith('settings.cssEmbeddedRemoveFailed'); + expect(host.textContent).toContain('settings.cssEmbedded'); + expect(removeButton()?.disabled).toBe(false); + await act(async () => removeButton()!.click()); + expect(mocks.setContent).toHaveBeenCalledTimes(2); + }); + + it('실패 응답도 알림으로 전달한다', async () => { + mocks.setContent.mockResolvedValueOnce({ + success: false, + error: 'TOO_LARGE', + }); + await render(); + await act(async () => removeButton()!.click()); + expect(showAlert).toHaveBeenCalledWith('settings.cssEmbeddedRemoveFailed'); + expect(host.textContent).toContain('settings.cssEmbedded'); + }); + + it('늦은 제거 응답이 더 최신 CSS 상태를 덮어쓰지 않는다', async () => { + const pending = deferred(); + mocks.setContent.mockReturnValue(pending.promise); + await render(); + act(() => removeButton()!.click()); + await render({ customCSSPath: file.path, customCSSContent: '.new {}' }); + await act(async () => pending.resolve({ success: true })); + expect(host.textContent).toContain('keys.css'); + expect(host.textContent).not.toContain('settings.noCssFile'); + expect(removeButton()).toBeNull(); + }); +}); diff --git a/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx b/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx index b7b06e3ee..1290fc0b8 100644 --- a/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx +++ b/src/renderer/components/main/SettingsPanel/CssPanelContent.tsx @@ -19,6 +19,8 @@ import { PANEL_ROW_NAME_INACTIVE_CLASS, PANEL_SECTION_CLASS, PANEL_STATUS_BADGE_CLASS, + PANEL_TOKEN_CHIP_CLASS, + PANEL_TOKEN_REMOVE_CLASS, } from '@components/main/SettingsPanel/panelChrome'; import { SettingToggleRow } from '@components/main/common/SettingRow'; import { SETTINGS_LABEL_CLASS, SETTINGS_ROW_CLASS } from '@utils/cardRecipes'; @@ -32,6 +34,7 @@ import type { CustomCssHistoryItem, } from '@src/types/plugin/api'; import { cssApi } from '@api/modules/cssApi'; +import { useSingleFlightAction } from '@hooks/useSingleFlightAction'; const CSS_HISTORY_ERROR_CODES: ReadonlySet = new Set([ 'PATH_NOT_AUTHORIZED', @@ -46,6 +49,7 @@ const CSS_HISTORY_ERROR_CODES: ReadonlySet = new Set([ interface CssPanelContentProps { useCustomCSS: boolean; customCSSPath: string | null; + customCSSContent: string; onToggleCustomCSS: () => void; showAlert: (msg: string) => void; onClose: () => void; @@ -59,12 +63,14 @@ let cssHistoryCache: CustomCssHistoryItem[] | null = null; const CssPanelContent = ({ useCustomCSS, customCSSPath, + customCSSContent, onToggleCustomCSS, showAlert, onClose, onHistoryCountChange, }: CssPanelContentProps) => { const { t } = useTranslation(); + const hasEmbeddedCSS = !customCSSPath && customCSSContent.length > 0; const [history, setHistory] = React.useState( cssHistoryCache ?? [], @@ -111,10 +117,28 @@ const CssPanelContent = ({ void refreshHistory(); }, [onHistoryCountChange, refreshHistory]); + // 파일 없는 원문만 비운다 - reset은 마스터 토글까지 꺼서 누른 순간 부작용처럼 보인다 + const { run: handleRemoveEmbedded, pending: isRemoving } = + useSingleFlightAction(async () => { + if (!hasEmbeddedCSS || loadingNewRef.current || pendingPathRef.current) + return; + try { + const result = await cssApi.setContent(''); + if (!result.success) { + showAlert(t('settings.cssEmbeddedRemoveFailed')); + } + } catch (error) { + console.error('Failed to remove embedded CSS', error); + showAlert(t('settings.cssEmbeddedRemoveFailed')); + } + }); + + const isChangingCSS = isRemoving || isLoadingNew || pendingPath !== null; + // 성공 시 스토어 갱신은 css:content 이벤트 구독(useAppBootstrap)에 일임 // 응답으로 직접 쓰면 더 최신 이벤트를 이전 응답이 되덮을 수 있음 const handleActivate = async (item: CustomCssHistoryItem): Promise => { - if (pendingPathRef.current) return; + if (pendingPathRef.current || loadingNewRef.current || isRemoving) return; if (item.path === customCSSPath) return; pendingPathRef.current = item.path; setPendingPath(item.path); @@ -152,7 +176,7 @@ const CssPanelContent = ({ }; const handleLoadNew = async (): Promise => { - if (loadingNewRef.current) return; + if (loadingNewRef.current || pendingPathRef.current || isRemoving) return; loadingNewRef.current = true; setIsLoadingNew(true); try { @@ -187,21 +211,52 @@ const CssPanelContent = ({ checked={useCustomCSS} onToggle={onToggleCustomCSS} /> - {/* 적용 중인 파일 - 전체 경로는 툴팁 */} + {/* 현재 CSS - 파일이면 이름(경로는 툴팁), 파일 없는 원문이면 제거 가능한 토큰 칩 */}
- {t('settings.cssActiveFile')} - - - {customCSSPath && customCSSPath.length > 0 - ? pathBaseName(customCSSPath) - : t('settings.noCssFile')} + {t('settings.cssSource')} + {hasEmbeddedCSS ? ( + + {t('settings.cssEmbedded')} + + + ) : ( + + {customCSSPath && customCSSPath.length > 0 + ? pathBaseName(customCSSPath) + : t('settings.noCssFile')} + + )}
@@ -263,9 +318,9 @@ const CssPanelContent = ({ event.stopPropagation(); void handleActivate(item); }} - disabled={!available || pendingPath !== null} + disabled={!available || isChangingCSS} className={`${PANEL_PILL_CLASS} ${ - available && pendingPath === null + available && !isChangingCSS ? FILL_QUIET_CLASS : FILL_QUIET_DISABLED_CLASS }`} @@ -285,9 +340,9 @@ const CssPanelContent = ({