@qijenchen/design-system 0.1.0-beta.83 → 0.1.0-beta.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +8 -8
- package/README.md +3 -2
- package/dist/components/Alert/alert.js.map +1 -1
- package/dist/components/AppShell/_demo-helpers.d.ts.map +1 -1
- package/dist/components/AppShell/app-shell.d.ts +1 -1
- package/dist/components/AppShell/app-shell.d.ts.map +1 -1
- package/dist/components/AppShell/app-shell.js +22 -2
- package/dist/components/AppShell/app-shell.js.map +1 -1
- package/dist/components/Avatar/avatar.d.ts.map +1 -1
- package/dist/components/Avatar/avatar.js +4 -4
- package/dist/components/Avatar/avatar.js.map +1 -1
- package/dist/components/Breadcrumb/breadcrumb.d.ts.map +1 -1
- package/dist/components/Breadcrumb/breadcrumb.js +1 -1
- package/dist/components/Breadcrumb/breadcrumb.js.map +1 -1
- package/dist/components/Button/button.d.ts +2 -2
- package/dist/components/Button/button.d.ts.map +1 -1
- package/dist/components/Button/button.js +12 -3
- package/dist/components/Button/button.js.map +1 -1
- package/dist/components/Calendar/calendar.d.ts +13 -5
- package/dist/components/Calendar/calendar.d.ts.map +1 -1
- package/dist/components/Calendar/calendar.js +7 -1
- package/dist/components/Calendar/calendar.js.map +1 -1
- package/dist/components/Carousel/carousel.d.ts.map +1 -1
- package/dist/components/Carousel/carousel.js +8 -3
- package/dist/components/Carousel/carousel.js.map +1 -1
- package/dist/components/Chart/chart.d.ts.map +1 -1
- package/dist/components/Chart/chart.js +1 -0
- package/dist/components/Chart/chart.js.map +1 -1
- package/dist/components/Checkbox/checkbox.d.ts +1 -1
- package/dist/components/Checkbox/checkbox.d.ts.map +1 -1
- package/dist/components/Checkbox/checkbox.js +8 -4
- package/dist/components/Checkbox/checkbox.js.map +1 -1
- package/dist/components/Chip/chip.d.ts +1 -1
- package/dist/components/Chip/chip.d.ts.map +1 -1
- package/dist/components/Chip/chip.js.map +1 -1
- package/dist/components/Coachmark/coachmark.d.ts +10 -3
- package/dist/components/Coachmark/coachmark.d.ts.map +1 -1
- package/dist/components/Coachmark/coachmark.js.map +1 -1
- package/dist/components/Combobox/combobox.d.ts +5 -1
- package/dist/components/Combobox/combobox.d.ts.map +1 -1
- package/dist/components/Combobox/combobox.js +30 -13
- package/dist/components/Combobox/combobox.js.map +1 -1
- package/dist/components/DataTable/cell-registry.d.ts.map +1 -1
- package/dist/components/DataTable/cell-registry.js +53 -62
- package/dist/components/DataTable/cell-registry.js.map +1 -1
- package/dist/components/DataTable/column-types.d.ts +10 -4
- package/dist/components/DataTable/column-types.d.ts.map +1 -1
- package/dist/components/DataTable/column-types.js.map +1 -1
- package/dist/components/DataTable/data-table-column-visibility-panel.d.ts +1 -1
- package/dist/components/DataTable/data-table-column-visibility-panel.js +183 -0
- package/dist/components/DataTable/data-table-column-visibility-panel.js.map +1 -0
- package/dist/components/DataTable/data-table-filter-panel.d.ts.map +1 -1
- package/dist/components/DataTable/data-table-filter-panel.js +607 -0
- package/dist/components/DataTable/data-table-filter-panel.js.map +1 -0
- package/dist/components/DataTable/data-table-interaction-layer.d.ts +11 -15
- package/dist/components/DataTable/data-table-interaction-layer.d.ts.map +1 -1
- package/dist/components/DataTable/data-table-interaction-layer.js +1 -3
- package/dist/components/DataTable/data-table-interaction-layer.js.map +1 -1
- package/dist/components/DataTable/data-table-sort-manager.js +140 -0
- package/dist/components/DataTable/data-table-sort-manager.js.map +1 -0
- package/dist/components/DataTable/data-table.d.ts +5 -6
- package/dist/components/DataTable/data-table.d.ts.map +1 -1
- package/dist/components/DataTable/data-table.js +36 -15
- package/dist/components/DataTable/data-table.js.map +1 -1
- package/dist/components/DataTable/filter-operators.js +140 -0
- package/dist/components/DataTable/filter-operators.js.map +1 -0
- package/dist/components/DataTable/filter-tree.js +312 -0
- package/dist/components/DataTable/filter-tree.js.map +1 -0
- package/dist/components/DataTable/index.d.ts +3 -0
- package/dist/components/DataTable/index.d.ts.map +1 -1
- package/dist/components/DataTable/index.js +12 -1
- package/dist/components/DataTable/index.js.map +1 -1
- package/dist/components/DataTable/lib/column-meta.js +25 -0
- package/dist/components/DataTable/lib/column-meta.js.map +1 -0
- package/dist/components/DateGrid/date-grid.d.ts.map +1 -1
- package/dist/components/DateGrid/date-grid.js +3 -1
- package/dist/components/DateGrid/date-grid.js.map +1 -1
- package/dist/components/DatePicker/date-picker.d.ts +7 -1
- package/dist/components/DatePicker/date-picker.d.ts.map +1 -1
- package/dist/components/DatePicker/date-picker.js +105 -74
- package/dist/components/DatePicker/date-picker.js.map +1 -1
- package/dist/components/Dialog/dialog.d.ts +20 -3
- package/dist/components/Dialog/dialog.d.ts.map +1 -1
- package/dist/components/Dialog/dialog.js +38 -20
- package/dist/components/Dialog/dialog.js.map +1 -1
- package/dist/components/DropdownMenu/dropdown-menu.d.ts.map +1 -1
- package/dist/components/DropdownMenu/dropdown-menu.js +3 -1
- package/dist/components/DropdownMenu/dropdown-menu.js.map +1 -1
- package/dist/components/Field/field-context.d.ts +45 -5
- package/dist/components/Field/field-context.d.ts.map +1 -1
- package/dist/components/Field/field-context.js +19 -0
- package/dist/components/Field/field-context.js.map +1 -1
- package/dist/components/Field/field-edit-keys.d.ts +21 -0
- package/dist/components/Field/field-edit-keys.d.ts.map +1 -0
- package/dist/components/Field/field-edit-keys.js +19 -0
- package/dist/components/Field/field-edit-keys.js.map +1 -0
- package/dist/components/Field/field-types.d.ts +20 -1
- package/dist/components/Field/field-types.d.ts.map +1 -1
- package/dist/components/Field/field-types.js.map +1 -1
- package/dist/components/Field/field-wrapper.d.ts +3 -2
- package/dist/components/Field/field-wrapper.d.ts.map +1 -1
- package/dist/components/Field/field-wrapper.js +38 -39
- package/dist/components/Field/field-wrapper.js.map +1 -1
- package/dist/components/Field/field.d.ts +2 -1
- package/dist/components/Field/field.d.ts.map +1 -1
- package/dist/components/Field/field.js.map +1 -1
- package/dist/components/FieldControlGroup/field-control-group.d.ts +1 -1
- package/dist/components/FieldControlGroup/field-control-group.js.map +1 -1
- package/dist/components/FileItem/file-item.d.ts.map +1 -1
- package/dist/components/FileItem/file-item.js +1 -1
- package/dist/components/FileItem/file-item.js.map +1 -1
- package/dist/components/FileUpload/file-upload.d.ts +1 -1
- package/dist/components/FileUpload/file-upload.js.map +1 -1
- package/dist/components/FileViewer/file-viewer.d.ts.map +1 -1
- package/dist/components/FileViewer/file-viewer.js +7 -2
- package/dist/components/FileViewer/file-viewer.js.map +1 -1
- package/dist/components/FileViewer/image-renderer.d.ts.map +1 -1
- package/dist/components/FileViewer/image-renderer.js +22 -1
- package/dist/components/FileViewer/image-renderer.js.map +1 -1
- package/dist/components/HoverCard/hover-card.d.ts.map +1 -1
- package/dist/components/HoverCard/hover-card.js +5 -3
- package/dist/components/HoverCard/hover-card.js.map +1 -1
- package/dist/components/InlineEdit/index.d.ts +2 -0
- package/dist/components/InlineEdit/index.d.ts.map +1 -0
- package/dist/components/InlineEdit/index.js +5 -0
- package/dist/components/InlineEdit/index.js.map +1 -0
- package/dist/components/InlineEdit/inline-edit.d.ts +59 -0
- package/dist/components/InlineEdit/inline-edit.d.ts.map +1 -0
- package/dist/components/InlineEdit/inline-edit.js +164 -0
- package/dist/components/InlineEdit/inline-edit.js.map +1 -0
- package/dist/components/Input/input.d.ts +6 -11
- package/dist/components/Input/input.d.ts.map +1 -1
- package/dist/components/Input/input.js +43 -13
- package/dist/components/Input/input.js.map +1 -1
- package/dist/components/LinkInput/link-input.d.ts +2 -2
- package/dist/components/LinkInput/link-input.d.ts.map +1 -1
- package/dist/components/LinkInput/link-input.js +6 -5
- package/dist/components/LinkInput/link-input.js.map +1 -1
- package/dist/components/Menu/menu-item.d.ts +14 -2
- package/dist/components/Menu/menu-item.d.ts.map +1 -1
- package/dist/components/Menu/menu-item.js +25 -3
- package/dist/components/Menu/menu-item.js.map +1 -1
- package/dist/components/NumberInput/number-input.d.ts +3 -3
- package/dist/components/NumberInput/number-input.d.ts.map +1 -1
- package/dist/components/NumberInput/number-input.js +6 -5
- package/dist/components/NumberInput/number-input.js.map +1 -1
- package/dist/components/OverflowIndicator/overflow-indicator.d.ts.map +1 -1
- package/dist/components/OverflowIndicator/overflow-indicator.js +2 -6
- package/dist/components/OverflowIndicator/overflow-indicator.js.map +1 -1
- package/dist/components/Pagination/pagination.js +1 -1
- package/dist/components/Pagination/pagination.js.map +1 -1
- package/dist/components/PeoplePicker/index.js +7 -1
- package/dist/components/PeoplePicker/index.js.map +1 -1
- package/dist/components/PeoplePicker/people-picker.d.ts +9 -2
- package/dist/components/PeoplePicker/people-picker.d.ts.map +1 -1
- package/dist/components/PeoplePicker/people-picker.js +23 -10
- package/dist/components/PeoplePicker/people-picker.js.map +1 -1
- package/dist/components/PeoplePicker/person-display.d.ts +6 -3
- package/dist/components/PeoplePicker/person-display.d.ts.map +1 -1
- package/dist/components/PeoplePicker/person-display.js +19 -14
- package/dist/components/PeoplePicker/person-display.js.map +1 -1
- package/dist/components/Popover/popover.d.ts.map +1 -1
- package/dist/components/Popover/popover.js +3 -1
- package/dist/components/Popover/popover.js.map +1 -1
- package/dist/components/ProfileCard/profile-card.d.ts +2 -2
- package/dist/components/ProfileCard/profile-card.js +3 -3
- package/dist/components/ProfileCard/profile-card.js.map +1 -1
- package/dist/components/RadioGroup/radio-group.d.ts.map +1 -1
- package/dist/components/RadioGroup/radio-group.js +15 -9
- package/dist/components/RadioGroup/radio-group.js.map +1 -1
- package/dist/components/ScrollArea/scroll-area.d.ts +11 -1
- package/dist/components/ScrollArea/scroll-area.d.ts.map +1 -1
- package/dist/components/ScrollArea/scroll-area.js +6 -3
- package/dist/components/ScrollArea/scroll-area.js.map +1 -1
- package/dist/components/Select/select.d.ts +46 -5
- package/dist/components/Select/select.d.ts.map +1 -1
- package/dist/components/Select/select.js +109 -28
- package/dist/components/Select/select.js.map +1 -1
- package/dist/components/SelectMenu/select-menu.d.ts +3 -1
- package/dist/components/SelectMenu/select-menu.d.ts.map +1 -1
- package/dist/components/SelectMenu/select-menu.js +8 -1
- package/dist/components/SelectMenu/select-menu.js.map +1 -1
- package/dist/components/SelectionControl/boolean-value.d.ts +11 -0
- package/dist/components/SelectionControl/boolean-value.d.ts.map +1 -0
- package/dist/components/SelectionControl/boolean-value.js +23 -0
- package/dist/components/SelectionControl/boolean-value.js.map +1 -0
- package/dist/components/SelectionControl/index.d.ts +1 -0
- package/dist/components/SelectionControl/index.d.ts.map +1 -1
- package/dist/components/SelectionControl/index.js +2 -0
- package/dist/components/SelectionControl/index.js.map +1 -1
- package/dist/components/Sheet/index.js +2 -1
- package/dist/components/Sheet/sheet.d.ts +7 -1
- package/dist/components/Sheet/sheet.d.ts.map +1 -1
- package/dist/components/Sheet/sheet.js +5 -2
- package/dist/components/Sheet/sheet.js.map +1 -1
- package/dist/components/Sidebar/sidebar.d.ts +9 -4
- package/dist/components/Sidebar/sidebar.d.ts.map +1 -1
- package/dist/components/Sidebar/sidebar.js +8 -10
- package/dist/components/Sidebar/sidebar.js.map +1 -1
- package/dist/components/Slider/slider.d.ts.map +1 -1
- package/dist/components/Slider/slider.js.map +1 -1
- package/dist/components/Switch/switch.d.ts +6 -6
- package/dist/components/Switch/switch.d.ts.map +1 -1
- package/dist/components/Switch/switch.js +3 -2
- package/dist/components/Switch/switch.js.map +1 -1
- package/dist/components/Tabs/tabs.d.ts.map +1 -1
- package/dist/components/Tabs/tabs.js +15 -1
- package/dist/components/Tabs/tabs.js.map +1 -1
- package/dist/components/Textarea/textarea.d.ts +2 -2
- package/dist/components/Textarea/textarea.d.ts.map +1 -1
- package/dist/components/Textarea/textarea.js +50 -42
- package/dist/components/Textarea/textarea.js.map +1 -1
- package/dist/components/TimePicker/time-columns.d.ts.map +1 -1
- package/dist/components/TimePicker/time-columns.js +42 -38
- package/dist/components/TimePicker/time-columns.js.map +1 -1
- package/dist/components/TimePicker/time-picker.d.ts.map +1 -1
- package/dist/components/TimePicker/time-picker.js +8 -6
- package/dist/components/TimePicker/time-picker.js.map +1 -1
- package/dist/components/Tooltip/tooltip.d.ts.map +1 -1
- package/dist/components/Tooltip/tooltip.js +7 -5
- package/dist/components/Tooltip/tooltip.js.map +1 -1
- package/dist/components/TreeView/tree-view.d.ts +56 -1
- package/dist/components/TreeView/tree-view.d.ts.map +1 -1
- package/dist/components/TreeView/tree-view.js +211 -12
- package/dist/components/TreeView/tree-view.js.map +1 -1
- package/dist/index.d.ts +115 -58
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +93 -108
- package/dist/index.js.map +1 -1
- package/dist/patterns/element-anatomy/item-anatomy.d.ts +7 -6
- package/dist/patterns/element-anatomy/item-anatomy.d.ts.map +1 -1
- package/dist/patterns/element-anatomy/item-anatomy.js.map +1 -1
- package/dist/tokens/categorical-color.js +15 -0
- package/dist/tokens/categorical-color.js.map +1 -1
- package/dist/tokens/motion/motion.d.ts +7 -6
- package/dist/tokens/motion/motion.d.ts.map +1 -1
- package/dist/tokens/motion/motion.js +6 -6
- package/dist/tokens/motion/motion.js.map +1 -1
- package/dist/tokens/motion/overlay-motion.d.ts +19 -0
- package/dist/tokens/motion/overlay-motion.d.ts.map +1 -0
- package/dist/tokens/motion/overlay-motion.js +9 -0
- package/dist/tokens/motion/overlay-motion.js.map +1 -0
- package/dist/tokens/uiSize/icon-size.js +5 -0
- package/dist/tokens/uiSize/icon-size.js.map +1 -0
- package/ds-canonical/fork/governance.lock +31 -19
- package/ds-canonical/fork/hooks/_log-fire.sh +46 -0
- package/ds-canonical/fork/hooks/block_prototype_imports.py +15 -4
- package/ds-canonical/fork/hooks/check_consumer_app_invariants.sh +145 -13
- package/ds-canonical/fork/hooks/check_ds_anchor_preflight.sh +2 -1
- package/ds-canonical/fork/hooks/check_dynamic_tailwind_class.sh +47 -0
- package/ds-canonical/fork/hooks/check_escape_marker_abuse.sh +27 -0
- package/ds-canonical/fork/hooks/check_field_family_invariants.sh +61 -1
- package/ds-canonical/fork/hooks/check_layout_space_magic_numbers.sh +28 -4
- package/ds-canonical/fork/hooks/check_opacity_token_usage.sh +28 -0
- package/ds-canonical/fork/hooks/check_story_determinism.sh +2 -0
- package/ds-canonical/fork/hooks/check_story_invariants.sh +119 -0
- package/ds-canonical/fork/hooks/check_tabs_content_chrome_body_double_gap.sh +29 -0
- package/ds-canonical/fork/manifest.json +15 -0
- package/ds-canonical/fork/preamble.md +20 -18
- package/ds-canonical/fork/skills/bug-fix-rhythm/SKILL.md +3 -3
- package/ds-canonical/fork/skills/code-quality-audit/SKILL.md +3 -3
- package/ds-canonical/fork/skills/delivery-handoff/SKILL.md +1 -1
- package/ds-canonical/fork/skills/product-ui-audit/SKILL.md +3 -3
- package/ds-canonical/fork/skills/propose-options/SKILL.md +3 -3
- package/ds-canonical/fork/skills/prototype/SKILL.md +1 -1
- package/ds-canonical/fork/skills/scan-similar-bugs/SKILL.md +2 -2
- package/ds-canonical/fork/skills/visual-audit/SKILL.md +2 -2
- package/ds-canonical/hooks/README.md +1 -1
- package/ds-canonical/hooks/block_prototype_imports.py +15 -4
- package/ds-canonical/hooks/check_audit_post_report_validator.sh +65 -7
- package/ds-canonical/hooks/check_codex_brief_invariants.sh +28 -1
- package/ds-canonical/hooks/check_consumer_app_invariants.sh +145 -13
- package/ds-canonical/hooks/check_ds_anchor_preflight.sh +2 -1
- package/ds-canonical/hooks/check_dynamic_tailwind_class.sh +47 -0
- package/ds-canonical/hooks/check_escape_marker_abuse.sh +27 -0
- package/ds-canonical/hooks/check_field_family_invariants.sh +61 -1
- package/ds-canonical/hooks/check_layout_space_magic_numbers.sh +28 -4
- package/ds-canonical/hooks/check_naming_and_abstraction.sh +1 -1
- package/ds-canonical/hooks/check_opacity_token_usage.sh +28 -0
- package/ds-canonical/hooks/check_pixel_quantified_audit.sh +3 -2
- package/ds-canonical/hooks/check_post_main_ssot_propagate.sh +6 -1
- package/ds-canonical/hooks/check_ssot_header_declaration.sh +3 -1
- package/ds-canonical/hooks/check_story_determinism.sh +2 -0
- package/ds-canonical/hooks/check_substantive_edit_approval_preflight.sh +8 -7
- package/ds-canonical/hooks/check_tabs_content_chrome_body_double_gap.sh +29 -0
- package/ds-canonical/hooks/lib/_story_compile_drift.sh +1 -1
- package/ds-canonical/hooks/lib/_token_hygiene.sh +10 -1
- package/ds-canonical/hooks/stop_self_audit.sh +31 -0
- package/ds-canonical/hooks/tests/test_block_prototype_imports.sh +24 -0
- package/ds-canonical/hooks/tests/test_check_audit_post_report_validator.sh +44 -0
- package/ds-canonical/hooks/tests/test_check_codex_brief_invariants.sh +43 -2
- package/ds-canonical/hooks/tests/test_check_consumer_ds_primitive_misuse.sh +112 -0
- package/ds-canonical/hooks/tests/test_check_consumer_story_baseline.sh +9 -2
- package/ds-canonical/hooks/tests/test_check_dynamic_tailwind_class.sh +76 -0
- package/ds-canonical/hooks/tests/test_check_escape_marker_abuse.sh +24 -0
- package/ds-canonical/hooks/tests/test_check_field_family_invariants.sh +27 -2
- package/ds-canonical/hooks/tests/test_check_opacity_token_usage.sh +39 -0
- package/ds-canonical/hooks/tests/test_check_tabs_content_chrome_body_double_gap.sh +76 -0
- package/ds-canonical/references/audit-coverage-vs-24-checklist.md +1 -1
- package/ds-canonical/references/build-ui-canonicals.md +2 -1
- package/ds-canonical/references/failure-class-registry.json +145 -0
- package/ds-canonical/references/governance-audit-coverage.md +33 -0
- package/ds-canonical/references/item-anatomy-recipe.md +2 -2
- package/ds-canonical/references/scenario-definition.md +6 -9
- package/ds-canonical/references/ssot-consultation.md +2 -2
- package/ds-canonical/references/ssot-index.md +5 -4
- package/ds-canonical/references/story-baseline-registry.json +8 -2
- package/ds-canonical/references/structural-token-retention.md +6 -9
- package/ds-canonical/rules/meta-patterns.md +8 -8
- package/ds-canonical/rules/self-verify.md +4 -3
- package/ds-canonical/rules/story-rules.md +2 -2
- package/ds-canonical/rules/ui-development.md +2 -2
- package/ds-canonical/skills/bug-fix-rhythm/SKILL.md +3 -3
- package/ds-canonical/skills/code-quality-audit/SKILL.md +3 -3
- package/ds-canonical/skills/codex-collab/SKILL.md +9 -7
- package/ds-canonical/skills/codex-collab/references/brief-template.md +1 -1
- package/ds-canonical/skills/codify-principle/SKILL.md +2 -2
- package/ds-canonical/skills/component-quality-gate/SKILL.md +1 -1
- package/ds-canonical/skills/component-quality-gate/references/checklist.md +2 -2
- package/ds-canonical/skills/deep-audit-cross-codex/SKILL.md +51 -58
- package/ds-canonical/skills/deep-audit-cross-codex/references/phase-a-workflow.md +29 -0
- package/ds-canonical/skills/deep-audit-cross-codex/references/phase-b-codex-brief.md +29 -10
- package/ds-canonical/skills/deep-audit-cross-codex/references/triage-rubric.md +21 -0
- package/ds-canonical/skills/delivery-handoff/SKILL.md +1 -1
- package/ds-canonical/skills/design-system-audit/SKILL.md +26 -25
- package/ds-canonical/skills/design-system-audit/references/audit-prompts.md +13 -1
- package/ds-canonical/skills/design-system-audit/references/rule-placement.md +1 -1
- package/ds-canonical/skills/governance-health/SKILL.md +1 -1
- package/ds-canonical/skills/knowledge-prune/SKILL.md +3 -0
- package/ds-canonical/skills/knowledge-prune/references/prune-dimensions-d5-d10.md +1 -0
- package/ds-canonical/skills/new-component/SKILL.md +5 -5
- package/ds-canonical/skills/new-component/references/new-component-checklist.md +1 -1
- package/ds-canonical/skills/product-ui-audit/SKILL.md +3 -3
- package/ds-canonical/skills/propose-options/SKILL.md +3 -3
- package/ds-canonical/skills/prototype/SKILL.md +1 -1
- package/ds-canonical/skills/scan-similar-bugs/SKILL.md +2 -2
- package/ds-canonical/skills/story-auto-compile-migrate/SKILL.md +3 -3
- package/ds-canonical/skills/story-writing/SKILL.md +3 -3
- package/ds-canonical/skills/visual-audit/SKILL.md +2 -2
- package/ds-story-manifest.json +29 -20
- package/llms-full.txt +22 -1
- package/llms.txt +5 -4
- package/package.json +7 -3
- package/src/components/Accordion/accordion.principles.stories.tsx +1 -1
- package/src/components/Accordion/accordion.stories.tsx +16 -1
- package/src/components/Alert/alert.principles.stories.tsx +1 -1
- package/src/components/Alert/alert.spec.md +8 -10
- package/src/components/Alert/alert.stories.tsx +28 -28
- package/src/components/Alert/alert.tsx +1 -1
- package/src/components/AppShell/_demo-helpers.tsx +6 -3
- package/src/components/AppShell/app-shell.spec.md +4 -4
- package/src/components/AppShell/app-shell.tsx +28 -2
- package/src/components/AspectRatio/aspect-ratio.spec.md +1 -1
- package/src/components/AspectRatio/aspect-ratio.stories.tsx +7 -89
- package/src/components/Avatar/avatar.anatomy.stories.tsx +7 -3
- package/src/components/Avatar/avatar.principles.stories.tsx +19 -15
- package/src/components/Avatar/avatar.spec.md +2 -2
- package/src/components/Avatar/avatar.stories.tsx +8 -6
- package/src/components/Avatar/avatar.tsx +13 -5
- package/src/components/Badge/badge.principles.stories.tsx +1 -1
- package/src/components/Badge/badge.spec.md +2 -0
- package/src/components/Badge/badge.stories.tsx +4 -2
- package/src/components/Breadcrumb/breadcrumb.principles.stories.tsx +6 -4
- package/src/components/Breadcrumb/breadcrumb.stories.tsx +6 -5
- package/src/components/Breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/BulkActionBar/bulk-action-bar.spec.md +4 -2
- package/src/components/Button/button.principles.stories.tsx +1 -1
- package/src/components/Button/button.spec.md +2 -2
- package/src/components/Button/button.tsx +18 -3
- package/src/components/Calendar/calendar.anatomy.stories.tsx +12 -10
- package/src/components/Calendar/calendar.principles.stories.tsx +14 -6
- package/src/components/Calendar/calendar.spec.md +5 -4
- package/src/components/Calendar/calendar.stories.tsx +7 -4
- package/src/components/Calendar/calendar.tsx +26 -7
- package/src/components/Carousel/carousel.anatomy.stories.tsx +5 -4
- package/src/components/Carousel/carousel.spec.md +2 -2
- package/src/components/Carousel/carousel.tsx +11 -3
- package/src/components/Chart/chart.anatomy.stories.tsx +7 -4
- package/src/components/Chart/chart.principles.stories.tsx +1 -1
- package/src/components/Chart/chart.stories.tsx +2 -8
- package/src/components/Chart/chart.tsx +3 -0
- package/src/components/Checkbox/checkbox.anatomy.stories.tsx +28 -49
- package/src/components/Checkbox/checkbox.principles.stories.tsx +1 -1
- package/src/components/Checkbox/checkbox.spec.md +11 -26
- package/src/components/Checkbox/checkbox.stories.tsx +11 -8
- package/src/components/Checkbox/checkbox.tsx +18 -13
- package/src/components/Chip/chip.anatomy.stories.tsx +44 -24
- package/src/components/Chip/chip.principles.stories.tsx +1 -1
- package/src/components/Chip/chip.spec.md +5 -4
- package/src/components/Chip/chip.tsx +5 -1
- package/src/components/CircularProgress/circular-progress.principles.stories.tsx +7 -4
- package/src/components/CircularProgress/circular-progress.spec.md +3 -3
- package/src/components/CircularProgress/circular-progress.stories.tsx +2 -2
- package/src/components/Coachmark/coachmark.anatomy.stories.tsx +2 -2
- package/src/components/Coachmark/coachmark.principles.stories.tsx +2 -1
- package/src/components/Coachmark/coachmark.spec.md +5 -5
- package/src/components/Coachmark/coachmark.stories.tsx +3 -1
- package/src/components/Coachmark/coachmark.tsx +10 -3
- package/src/components/Combobox/combobox.anatomy.stories.tsx +2 -2
- package/src/components/Combobox/combobox.tsx +53 -19
- package/src/components/Command/command.anatomy.stories.tsx +2 -2
- package/src/components/Command/command.principles.stories.tsx +1 -1
- package/src/components/Command/command.stories.tsx +42 -10
- package/src/components/DataTable/cell-registry.tsx +86 -63
- package/src/components/DataTable/column-types.ts +10 -1
- package/src/components/DataTable/data-table-column-visibility-panel.tsx +1 -1
- package/src/components/DataTable/data-table-filter-panel.tsx +4 -1
- package/src/components/DataTable/data-table-interaction-layer.tsx +15 -19
- package/src/components/DataTable/data-table.anatomy.stories.tsx +15 -13
- package/src/components/DataTable/data-table.principles.stories.tsx +9 -9
- package/src/components/DataTable/data-table.spec.md +32 -12
- package/src/components/DataTable/data-table.stories.tsx +16 -17
- package/src/components/DataTable/data-table.tsx +103 -60
- package/src/components/DataTable/index.ts +3 -0
- package/src/components/DateGrid/date-grid.anatomy.stories.tsx +4 -4
- package/src/components/DateGrid/date-grid.principles.stories.tsx +1 -1
- package/src/components/DateGrid/date-grid.spec.md +1 -1
- package/src/components/DateGrid/date-grid.tsx +3 -1
- package/src/components/DatePicker/date-picker.anatomy.stories.tsx +8 -8
- package/src/components/DatePicker/date-picker.principles.stories.tsx +3 -3
- package/src/components/DatePicker/date-picker.spec.md +3 -3
- package/src/components/DatePicker/date-picker.stories.tsx +4 -5
- package/src/components/DatePicker/date-picker.tsx +99 -45
- package/src/components/DescriptionList/description-list.anatomy.stories.tsx +12 -10
- package/src/components/DescriptionList/description-list.principles.stories.tsx +2 -2
- package/src/components/DescriptionList/description-list.spec.md +3 -3
- package/src/components/DescriptionList/description-list.stories.tsx +7 -6
- package/src/components/Dialog/dialog.anatomy.stories.tsx +1 -0
- package/src/components/Dialog/dialog.spec.md +9 -0
- package/src/components/Dialog/dialog.stories.tsx +66 -5
- package/src/components/Dialog/dialog.tsx +60 -25
- package/src/components/DropdownMenu/dropdown-menu.anatomy.stories.tsx +1 -1
- package/src/components/DropdownMenu/dropdown-menu.principles.stories.tsx +8 -6
- package/src/components/DropdownMenu/dropdown-menu.spec.md +5 -5
- package/src/components/DropdownMenu/dropdown-menu.tsx +18 -9
- package/src/components/Empty/empty.anatomy.stories.tsx +1 -1
- package/src/components/Empty/empty.spec.md +2 -2
- package/src/components/Field/field-context.ts +69 -5
- package/src/components/Field/field-controls.spec.md +62 -8
- package/src/components/Field/field-edit-keys.ts +66 -0
- package/src/components/Field/field-types.ts +58 -9
- package/src/components/Field/field-wrapper.tsx +50 -42
- package/src/components/Field/field.anatomy.stories.tsx +9 -6
- package/src/components/Field/field.spec.md +1 -1
- package/src/components/Field/field.stories.tsx +1 -1
- package/src/components/Field/field.tsx +2 -1
- package/src/components/Field/form-validation.spec.md +3 -3
- package/src/components/FieldControlGroup/field-control-group.principles.stories.tsx +6 -6
- package/src/components/FieldControlGroup/field-control-group.spec.md +9 -4
- package/src/components/FieldControlGroup/field-control-group.tsx +1 -1
- package/src/components/FileItem/file-item.anatomy.stories.tsx +1 -1
- package/src/components/FileItem/file-item.spec.md +5 -8
- package/src/components/FileItem/file-item.stories.tsx +36 -25
- package/src/components/FileItem/file-item.tsx +8 -6
- package/src/components/FileUpload/file-upload.anatomy.stories.tsx +6 -6
- package/src/components/FileUpload/file-upload.principles.stories.tsx +5 -5
- package/src/components/FileUpload/file-upload.spec.md +1 -1
- package/src/components/FileUpload/file-upload.tsx +1 -1
- package/src/components/FileViewer/file-viewer.anatomy.stories.tsx +4 -3
- package/src/components/FileViewer/file-viewer.principles.stories.tsx +3 -3
- package/src/components/FileViewer/file-viewer.spec.md +30 -12
- package/src/components/FileViewer/file-viewer.stories.tsx +1 -2
- package/src/components/FileViewer/file-viewer.tsx +12 -4
- package/src/components/FileViewer/image-renderer.tsx +35 -7
- package/src/components/HoverCard/hover-card.anatomy.stories.tsx +6 -2
- package/src/components/HoverCard/hover-card.principles.stories.tsx +6 -3
- package/src/components/HoverCard/hover-card.spec.md +5 -5
- package/src/components/HoverCard/hover-card.stories.tsx +16 -16
- package/src/components/HoverCard/hover-card.tsx +5 -3
- package/src/components/InlineEdit/index.ts +5 -0
- package/src/components/InlineEdit/inline-edit.spec.md +149 -0
- package/src/components/InlineEdit/inline-edit.stories.tsx +246 -0
- package/src/components/InlineEdit/inline-edit.tsx +287 -0
- package/src/components/Input/input.anatomy.stories.tsx +8 -7
- package/src/components/Input/input.principles.stories.tsx +2 -12
- package/src/components/Input/input.spec.md +13 -18
- package/src/components/Input/input.tsx +69 -22
- package/src/components/LinkInput/link-input.anatomy.stories.tsx +1 -1
- package/src/components/LinkInput/link-input.principles.stories.tsx +2 -2
- package/src/components/LinkInput/link-input.tsx +11 -10
- package/src/components/Menu/menu-item.anatomy.stories.tsx +3 -3
- package/src/components/Menu/menu-item.principles.stories.tsx +1 -1
- package/src/components/Menu/menu-item.spec.md +22 -15
- package/src/components/Menu/menu-item.stories.tsx +39 -11
- package/src/components/Menu/menu-item.tsx +38 -4
- package/src/components/Notice/notice.principles.stories.tsx +1 -1
- package/src/components/Notice/notice.spec.md +16 -3
- package/src/components/NumberInput/number-input.anatomy.stories.tsx +3 -3
- package/src/components/NumberInput/number-input.principles.stories.tsx +1 -1
- package/src/components/NumberInput/number-input.spec.md +4 -4
- package/src/components/NumberInput/number-input.tsx +19 -12
- package/src/components/OverflowIndicator/overflow-indicator.anatomy.stories.tsx +10 -8
- package/src/components/OverflowIndicator/overflow-indicator.principles.stories.tsx +3 -3
- package/src/components/OverflowIndicator/overflow-indicator.spec.md +4 -2
- package/src/components/OverflowIndicator/overflow-indicator.stories.tsx +71 -63
- package/src/components/OverflowIndicator/overflow-indicator.tsx +9 -8
- package/src/components/Pagination/pagination.spec.md +1 -1
- package/src/components/Pagination/pagination.stories.tsx +2 -6
- package/src/components/Pagination/pagination.tsx +1 -1
- package/src/components/PeoplePicker/people-picker.anatomy.stories.tsx +4 -4
- package/src/components/PeoplePicker/people-picker.principles.stories.tsx +2 -2
- package/src/components/PeoplePicker/people-picker.spec.md +6 -6
- package/src/components/PeoplePicker/people-picker.tsx +56 -16
- package/src/components/PeoplePicker/person-display.tsx +32 -16
- package/src/components/Popover/popover.anatomy.stories.tsx +2 -2
- package/src/components/Popover/popover.tsx +3 -1
- package/src/components/ProfileCard/profile-card.anatomy.stories.tsx +12 -23
- package/src/components/ProfileCard/profile-card.spec.md +3 -3
- package/src/components/ProfileCard/profile-card.tsx +6 -6
- package/src/components/ProgressBar/progress-bar.anatomy.stories.tsx +3 -1
- package/src/components/ProgressBar/progress-bar.principles.stories.tsx +1 -1
- package/src/components/ProgressBar/progress-bar.stories.tsx +50 -42
- package/src/components/RadioGroup/radio-group.anatomy.stories.tsx +10 -28
- package/src/components/RadioGroup/radio-group.stories.tsx +29 -32
- package/src/components/RadioGroup/radio-group.tsx +41 -15
- package/src/components/Rating/rating.spec.md +11 -3
- package/src/components/ScrollArea/scroll-area.anatomy.stories.tsx +1 -1
- package/src/components/ScrollArea/scroll-area.principles.stories.tsx +1 -1
- package/src/components/ScrollArea/scroll-area.spec.md +7 -3
- package/src/components/ScrollArea/scroll-area.stories.tsx +1 -1
- package/src/components/ScrollArea/scroll-area.tsx +17 -4
- package/src/components/Select/select.anatomy.stories.tsx +3 -3
- package/src/components/Select/select.spec.md +27 -17
- package/src/components/Select/select.stories.tsx +33 -0
- package/src/components/Select/select.tsx +190 -36
- package/src/components/SelectMenu/select-menu.anatomy.stories.tsx +4 -4
- package/src/components/SelectMenu/select-menu.spec.md +15 -7
- package/src/components/SelectMenu/select-menu.tsx +16 -3
- package/src/components/SelectionControl/boolean-value.tsx +39 -0
- package/src/components/SelectionControl/index.ts +1 -0
- package/src/components/SelectionControl/selection-item.anatomy.stories.tsx +2 -2
- package/src/components/SelectionControl/selection-item.principles.stories.tsx +2 -2
- package/src/components/SelectionControl/selection-item.spec.md +3 -3
- package/src/components/SelectionControl/selection-item.stories.tsx +3 -3
- package/src/components/Separator/separator.spec.md +2 -2
- package/src/components/Separator/separator.stories.tsx +6 -27
- package/src/components/Sheet/sheet.anatomy.stories.tsx +1 -1
- package/src/components/Sheet/sheet.principles.stories.tsx +1 -1
- package/src/components/Sheet/sheet.spec.md +2 -2
- package/src/components/Sheet/sheet.tsx +9 -5
- package/src/components/Sidebar/sidebar.anatomy.stories.tsx +16 -12
- package/src/components/Sidebar/sidebar.principles.stories.tsx +10 -8
- package/src/components/Sidebar/sidebar.spec.md +13 -15
- package/src/components/Sidebar/sidebar.stories.tsx +8 -7
- package/src/components/Sidebar/sidebar.tsx +37 -25
- package/src/components/Skeleton/skeleton.stories.tsx +4 -11
- package/src/components/Slider/slider.anatomy.stories.tsx +3 -3
- package/src/components/Slider/slider.principles.stories.tsx +2 -2
- package/src/components/Slider/slider.spec.md +5 -5
- package/src/components/Slider/slider.tsx +3 -1
- package/src/components/Steps/steps.anatomy.stories.tsx +4 -4
- package/src/components/Steps/steps.principles.stories.tsx +1 -1
- package/src/components/Steps/steps.spec.md +1 -1
- package/src/components/Switch/switch.anatomy.stories.tsx +7 -7
- package/src/components/Switch/switch.principles.stories.tsx +4 -4
- package/src/components/Switch/switch.spec.md +8 -8
- package/src/components/Switch/switch.stories.tsx +9 -24
- package/src/components/Switch/switch.tsx +17 -13
- package/src/components/Tabs/tabs.anatomy.stories.tsx +10 -7
- package/src/components/Tabs/tabs.spec.md +1 -1
- package/src/components/Tabs/tabs.tsx +21 -1
- package/src/components/Textarea/textarea.anatomy.stories.tsx +1 -1
- package/src/components/Textarea/textarea.principles.stories.tsx +1 -1
- package/src/components/Textarea/textarea.spec.md +4 -4
- package/src/components/Textarea/textarea.tsx +72 -46
- package/src/components/TimePicker/time-columns.tsx +6 -2
- package/src/components/TimePicker/time-picker.principles.stories.tsx +1 -1
- package/src/components/TimePicker/time-picker.spec.md +3 -3
- package/src/components/TimePicker/time-picker.tsx +16 -10
- package/src/components/Toast/toast.stories.tsx +7 -4
- package/src/components/Tooltip/tooltip.anatomy.stories.tsx +4 -4
- package/src/components/Tooltip/tooltip.principles.stories.tsx +3 -3
- package/src/components/Tooltip/tooltip.spec.md +1 -1
- package/src/components/Tooltip/tooltip.stories.tsx +4 -39
- package/src/components/Tooltip/tooltip.tsx +8 -6
- package/src/components/TreeView/tree-view.anatomy.stories.tsx +6 -3
- package/src/components/TreeView/tree-view.spec.md +42 -11
- package/src/components/TreeView/tree-view.stories.tsx +1 -0
- package/src/components/TreeView/tree-view.tsx +322 -17
- package/src/index.ts +584 -60
- package/src/patterns/action-bar/action-bar.spec.md +9 -0
- package/src/patterns/action-bar/action-bar.stories.tsx +6 -3
- package/src/patterns/element-anatomy/inline-action.spec.md +2 -0
- package/src/patterns/element-anatomy/item-anatomy.spec.md +14 -5
- package/src/patterns/element-anatomy/item-anatomy.stories.tsx +6 -4
- package/src/patterns/element-anatomy/item-anatomy.tsx +7 -6
- package/src/patterns/header-canonical/header-canonical.spec.md +5 -4
- package/src/patterns/overlay-surface/overlay-surface.spec.md +22 -1
- package/src/styles/tokens.css +3 -0
- package/src/tokens/README.md +1 -1
- package/src/tokens/color/color.spec.md +3 -3
- package/src/tokens/color/color.stories.tsx +3 -3
- package/src/tokens/density/density.stories.tsx +16 -3
- package/src/tokens/layoutSpace/layoutSpace.spec.md +3 -1
- package/src/tokens/motion/motion.css +33 -21
- package/src/tokens/motion/motion.spec.md +32 -15
- package/src/tokens/motion/motion.stories.tsx +58 -5
- package/src/tokens/motion/motion.ts +7 -6
- package/src/tokens/motion/overlay-motion.ts +26 -0
- package/src/tokens/orphan-tokens.spec.md +4 -4
- package/src/tokens/token-system.spec.md +1 -0
- package/src/tokens/uiSize/uiSize.spec.md +1 -1
- package/src/tokens/uiSize/uiSize.stories.tsx +4 -2
- package/src/tokens/utility-registry.json +160 -23
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-viewer.js","sources":["../../../src/components/FileViewer/file-viewer.tsx"],"sourcesContent":["// @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.\n// code-quality-allow: file-size — composite 拼裝(Toolbar / ZoomInput / InfoPanel / Filmstrip + Dialog shell + renderer registry);拆檔會把 useState/useEffect/key handler 跨檔同步過於複雜\nimport * as React from 'react'\nimport * as DialogPrimitive from '@radix-ui/react-dialog'\nimport {\n X as XIcon,\n Download,\n Info,\n ChevronLeft,\n ChevronRight,\n ChevronDown,\n Plus,\n Minus,\n File as FileIcon,\n FileText,\n} from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport { OVERLAY_SIDE_OFFSET } from '@/design-system/tokens/elevation/overlay-geometry'\nimport { Button } from '@/design-system/components/Button/button'\nimport { Separator } from '@/design-system/components/Separator/separator'\nimport { Input } from '@/design-system/components/Input/input'\nimport { Empty } from '@/design-system/components/Empty/empty'\nimport { AspectRatio } from '@/design-system/components/AspectRatio/aspect-ratio'\nimport { Textarea } from '@/design-system/components/Textarea/textarea'\nimport { Field, FieldLabel } from '@/design-system/components/Field/field'\nimport { DescriptionList, DescriptionItem } from '@/design-system/components/DescriptionList/description-list'\nimport { ItemInlineActionButton } from '@/design-system/patterns/element-anatomy/item-anatomy'\nimport { ChromeHeader } from '@/design-system/patterns/header-canonical/chrome-header'\nimport { ScrollArea } from '@/design-system/components/ScrollArea/scroll-area'\nimport {\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n} from '@/design-system/components/DropdownMenu/dropdown-menu'\nimport { ARROW_BUTTON_WIDTH,\n useScrollEdges,\n useScrollByPage,\n buildFadeMask,\n OverflowScrollArrow,\n} from '@/design-system/patterns/horizontal-overflow/horizontal-overflow'\nimport { useControllable } from '@/design-system/hooks/use-controllable'\nimport { canRenderImage } from './can-render-image'\nimport type {\n FileInfo,\n FileRenderer,\n FileRendererCapabilities,\n FileRendererProps,\n} from './file-viewer-types'\n\n/**\n * FileViewer — 可延伸的網頁檔案 preview shell(modal fullscreen)\n *\n * ── 定位 ──\n * 公開、composite 元件。consumer 傳 `files`,FileViewer 處理 overlay / toolbar /\n * keyboard / filmstrip / info panel 一切 chrome;檔案本體由 renderer registry\n * 按 file MIME 決定誰渲染(MVP 內建 ImageRenderer + FallbackRenderer)。\n *\n * ── 實作基礎 ──\n * 自建 composite,消費 DS primitives:\n * - Radix DialogPrimitive(焦點 trap / Esc / aria-modal,保有 shadcn 結構優勢)\n * - `<Empty>` / `<Button>` / `<Input variant=\"bare\">` / `<AspectRatio>` / `<Textarea>` / `<DropdownMenu>`\n * - `patterns/horizontal-overflow`(filmstrip 溢出捲動)\n * 不用 DS 的 `<Dialog>` wrapper:因為 FileViewer 需要 edge-to-edge fullscreen\n * (無 viewport inset / 無 rounded-lg / 無 maxWidth),Dialog 的這些預設都要覆寫。\n * 直接消費 Radix primitive 讓 shell 擁有完整 layout 控制權。\n *\n * ── Layout Family ──\n * 非 Family 1/2/3/4 — composite / multi-region(Toolbar / Viewport / Filmstrip +\n * 可選 InfoPanel)。見 `file-viewer.spec.md`「Layout Family」段。\n *\n * ── Extensibility ──\n * `registerFileRenderer(renderer)` 註冊新 renderer;shell 按註冊順序 iterate,\n * 第一個 `canRender(file)` 回 true 的渲染。FallbackRenderer 永遠兜底(未知檔案\n * 類型顯示 icon + 檔名 + download)。\n */\n\n// ─── Renderer Registry ────────────────────────────────────────────────────────\n\n/**\n * Fallback renderer — 無 renderer 能處理時兜底。\n * 顯示 Empty 佈局:icon + 檔名 + 「請下載檢視」提示。\n */\nconst FallbackRenderer: React.FC<FileRendererProps> = ({ file, onCapabilitiesChange }) => {\n // Contract(file-viewer-types.ts `onCapabilitiesChange` doc):「第一個 renderer mount\n // 時必須 emit」。fallback 不支援 zoom → emit { zoom: false };不 emit 的話 shell 會沿用\n // 上一個 renderer 的 capability(image → 未知檔切換時 toolbar 殘留 zoom controls,\n // `+`/`-`/`0`/`F` 快捷鍵也會對不支援 zoom 的檔案生效)。同 image-renderer.tsx mount emit pattern。\n React.useEffect(() => {\n onCapabilitiesChange({ zoom: false })\n }, [onCapabilitiesChange])\n\n return (\n <div className=\"w-full h-full flex items-center justify-center p-8\">\n <Empty\n icon={FileText}\n title={file.name}\n description={`無法在瀏覽器中預覽此檔案類型(${file.mimeType || 'unknown'})。請下載後檢視。`}\n />\n </div>\n )\n}\n\nconst fallbackRenderer: FileRenderer = {\n id: 'fallback',\n canRender: () => true,\n component: FallbackRenderer,\n}\n\n// 2026-07-06 D3 bundle fix:ImageRenderer 改 React.lazy 動態 import——FileViewer 是\n// rare-use modal overlay,react-zoom-pan-pinch(~12KB gz)不該在 viewer 從未打開時就進\n// consumer 首屏 bundle。canRenderImage(registry 判斷 + Filmstrip 分流需同步呼叫)已拆至\n// ./can-render-image 保持靜態 import。\nconst LazyImageRenderer = React.lazy(() =>\n import('./image-renderer').then((m) => ({ default: m.ImageRenderer })),\n)\n\nconst imageRenderer: FileRenderer = {\n id: 'image',\n canRender: canRenderImage,\n component: LazyImageRenderer,\n}\n\n// Registry 是 module-singleton:新 renderer 透過 registerFileRenderer 加入。\n// Fallback 永遠最後(兜底),因此用陣列第二段存放。\nconst userRegistered: FileRenderer[] = []\n\nexport function registerFileRenderer(renderer: FileRenderer): void {\n // 去重:同 id 則覆寫\n const existingIdx = userRegistered.findIndex((r) => r.id === renderer.id)\n if (existingIdx >= 0) {\n userRegistered[existingIdx] = renderer\n } else {\n userRegistered.push(renderer)\n }\n}\n\nfunction resolveRenderer(file: FileInfo): FileRenderer {\n // 先查 user registered,再 built-in,最後 fallback\n for (const r of userRegistered) {\n if (r.canRender(file)) return r\n }\n if (imageRenderer.canRender(file)) return imageRenderer\n return fallbackRenderer\n}\n\n// ─── Zoom presets ─────────────────────────────────────────────────────────────\n\ntype ZoomFit = 'fit-width' | 'fit-page'\n\nconst ZOOM_PRESETS: number[] = [10, 25, 50, 75, 100, 125, 150, 200, 400]\n// i18n-allow-block: DS defaults for zoom fit menu;consumer override via `labels.zoomFitOptions` (future) or fork\nconst ZOOM_FIT_OPTIONS: { value: ZoomFit; label: string }[] = [\n { value: 'fit-width', label: 'Fit to width' },\n { value: 'fit-page', label: 'Fit to page' },\n]\n\nfunction nextZoomIn(current: number): number {\n for (const p of ZOOM_PRESETS) {\n if (p > current) return p\n }\n return ZOOM_PRESETS[ZOOM_PRESETS.length - 1]\n}\nfunction nextZoomOut(current: number): number {\n for (let i = ZOOM_PRESETS.length - 1; i >= 0; i--) {\n if (ZOOM_PRESETS[i] < current) return ZOOM_PRESETS[i]\n }\n return ZOOM_PRESETS[0]\n}\n\n// ─── ZoomInput ────────────────────────────────────────────────────────────────\n\ninterface ZoomInputProps {\n value: number\n onChange: (next: number) => void\n onFit: (fit: ZoomFit) => void\n labels: Pick<Required<FileViewerLabels>, 'zoomInput' | 'zoomMenu'>\n}\n\n/**\n * ZoomInput — [−] [% input(bare)with ⌄ menu trigger] [+]\n *\n * 世界級對照:Figma zoom control / Adobe Acrobat / Google Slides zoom。\n *\n * ── 消費 DS primitive ──\n * - `<Button>` iconOnly size=\"sm\" 作 ±按鈕\n * - `<Input size=\"sm\" autoWidth>` 作 %輸入(Toolbar inline editing canonical;autoWidth 隨文字寬)\n * - Input `endSlot` escape hatch 包 `<DropdownMenuTrigger asChild>` + chevron 觸發 DropdownMenu\n * - `<DropdownMenu>` 作 preset + fit 選單(取代原先 Popover + 手刻 button list)\n *\n * ── 為什麼 inline(不抽獨立 primitive)──\n * 目前只 FileViewer 消費;MVP 階段遵循 YAGNI。當 PDF / Video viewer 也需要相同\n * primitive 時,再依「建立前必查既有 pattern」原則從 FileViewer 抽出升級。\n */\nconst ZoomInput: React.FC<ZoomInputProps> = ({ value, onChange, onFit, labels }) => {\n const [draft, setDraft] = React.useState<string>(`${value}%`)\n const [menuOpen, setMenuOpen] = React.useState(false)\n\n React.useEffect(() => {\n setDraft(`${value}%`)\n }, [value])\n\n const commitDraft = () => {\n const parsed = parseInt(draft.replace(/[^0-9]/g, ''), 10)\n if (Number.isFinite(parsed) && parsed > 0) {\n // 限 10–400 範圍,對齊 ImageRenderer MIN_SCALE/MAX_SCALE\n const clamped = Math.min(400, Math.max(10, parsed))\n onChange(clamped)\n setDraft(`${clamped}%`)\n } else {\n setDraft(`${value}%`)\n }\n }\n\n return (\n // zoom group = toolbar 按鈕群組,`gap-2`(8px)對齊本 DS 按鈕 gap canonical。\n <div className=\"inline-flex items-center gap-2\">\n {/* 縮小 */}\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Minus}\n aria-label=\"縮小\"\n disabled={value <= 10}\n onClick={() => onChange(nextZoomOut(value))}\n />\n\n {/* % Input + chevron 內嵌為 endSlot(ItemInlineActionButton 作 DropdownMenuTrigger):\n — Input body 可自由打字(chevron 是 Input 內部 element,body 區域 click 不觸發 menu)\n — Chevron 是 inline action,同時是 DropdownMenuTrigger → menu 精確 anchor 在 chevron 下方\n — 靠 Radix asChild + ItemInlineActionButton:視覺是 Input + endAction,行為是 chevron-as-trigger\n — 完全對齊 user AR:「只有 inline action 能觸發選單,menu 對齊 inline action」 */}\n <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n <Input\n size=\"sm\"\n autoWidth\n aria-label={labels.zoomInput}\n value={draft}\n onChange={(e) => setDraft(e.target.value)}\n onBlur={commitDraft}\n onKeyDown={(e) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n commitDraft()\n ;(e.target as HTMLInputElement).blur()\n }\n }}\n className=\"text-center tabular-nums\"\n endSlot={\n <DropdownMenuTrigger asChild>\n <ItemInlineActionButton\n icon={ChevronDown}\n aria-label={labels.zoomMenu}\n size=\"sm\"\n overlayTrigger\n />\n </DropdownMenuTrigger>\n }\n />\n {/* data-theme=\"dark\":DropdownMenuContent 走 Portal 到 document body 外,\n 不繼承 FileViewer 外層 data-theme=\"dark\",需顯式打 dark 讓選單跟 chrome 一致。\n **加 bg-surface-raised 強制用 dark token**(純 data-theme attr 在 Portal 不夠,\n Tailwind 條件 class + CSS variable 都要一起帶) */}\n <DropdownMenuContent\n align=\"end\"\n sideOffset={OVERLAY_SIDE_OFFSET} // 2026-07-04:M17 — elevation.spec「primitive default 必 import 該 const」\n // minWidth 對齊 trigger(Input autoWidth),menu 寬度 fit-content 更貼近觸發點視覺中心\n className=\"min-w-[9rem] w-auto bg-surface-raised text-foreground border-divider\"\n data-theme=\"dark\"\n >\n {/* 內層 data-theme 再覆蓋一次 — 確保 DropdownMenuItem children 都 resolve dark token */}\n <div data-theme=\"dark\" className=\"contents\">\n {ZOOM_FIT_OPTIONS.map((opt) => (\n <DropdownMenuItem\n key={opt.value}\n onSelect={() => onFit(opt.value)}\n >\n {opt.label}\n </DropdownMenuItem>\n ))}\n <DropdownMenuSeparator />\n {ZOOM_PRESETS.map((p) => {\n const selected = p === value\n return (\n <DropdownMenuItem\n key={p}\n onSelect={() => onChange(p)}\n data-state={selected ? 'checked' : undefined}\n className={cn(\n 'tabular-nums',\n selected && 'bg-neutral-selected',\n )}\n >\n {p}%\n </DropdownMenuItem>\n )\n })}\n </div>\n </DropdownMenuContent>\n </DropdownMenu>\n\n {/* 放大 */}\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Plus}\n aria-label=\"放大\"\n disabled={value >= 400}\n onClick={() => onChange(nextZoomIn(value))}\n />\n </div>\n )\n}\nZoomInput.displayName = 'ZoomInput'\n\n// ─── Toolbar ──────────────────────────────────────────────────────────────────\n\ninterface ToolbarProps {\n file: FileInfo\n capabilities: FileRendererCapabilities\n zoom: number\n onZoomChange: (z: number) => void\n onFit: (fit: ZoomFit) => void\n infoOpen: boolean\n onInfoToggle: () => void\n onDownload?: () => void\n allowDownload: boolean\n onClose: () => void\n labels: Required<FileViewerLabels>\n}\n\nconst Toolbar: React.FC<ToolbarProps> = ({\n file,\n capabilities,\n zoom,\n onZoomChange,\n onFit,\n infoOpen,\n onInfoToggle,\n onDownload,\n allowDownload,\n onClose,\n labels,\n}) => {\n return (\n <ChromeHeader\n // Density:繼承 page(2026-06-15 canonical)。原 lockDensity=\"lg\" 只鎖在 header、沒鎖 body →\n // InfoPanel header(px-loose@lg=24)與其 body(px-loose@page=16)左緣不對齊(圖二 bug)。移除 lock\n // → header + body 全繼承 page density,左緣對齊;FileViewer 全 surface 同一密度(non-special)。\n className={cn(\n // Chrome layer — `bg-surface-raised` 對齊 token semantic「遮蓋型浮層必須不透明」。\n // FileViewer 整體是 overlay,chrome 屬其 raised surface(同 DropdownMenuContent line 244)。\n // 不用 bg-surface(dark = white α8 半透明,outer 透明時失去 backdrop 洗白)。\n // 不用 bg-canvas(那是「頁面最底層」semantic,chrome 不是 page)。\n // ChromeHeader 自帶 flex/items-center/gap-2/shrink-0/h-chrome-header-height/border-b/px-loose\n 'bg-surface-raised',\n )}\n >\n {/* 檔名(左,佔據可用寬度,ellipsis)—— file-type icon 代表檔名的意象(這是什麼檔),\n 對齊 CLAUDE.md「icon 代表 label 意象時與 label 同色」原則:icon 走 text-foreground\n 不走 text-fg-muted(後者是裝飾性 / 輔助 icon 的色階) */}\n <div className=\"flex items-center gap-2 min-w-0 flex-1\">\n <FileIcon size={16} className=\"text-foreground shrink-0\" aria-hidden />\n <span\n className=\"text-body-lg text-foreground truncate\"\n title={file.name}\n >\n {file.name}\n </span>\n </div>\n\n {/* 按鈕順序 canonical:zoom → info → download → close(影響力遞增)\n action-bar 三分區:zoom(data op)/ info+download(action group)/ close(dismiss)\n dismiss 前分隔線 = action-bar「dismiss 跟動作分群」canonical\n\n ── gap-2 canonical(2026-04-21 follow-up)──\n 按鈕間距 **8px**(gap-2),對齊 Dialog footer `gap-2` / CLAUDE 按鈕間距 SSOT。\n zoom group(ZoomInput)內部同樣是 gap-2(見 `ZoomInput` L197),\n 與這裡 action-group-to-action-group 的 gap-2 一致。 */}\n <div className=\"flex items-center gap-2 shrink-0\">\n {capabilities.zoom && (\n <>\n {/* Zoom group:-/%/+/▼ 屬同類「縮放」操作,群組並在右側加分隔線跟其他動作分群 */}\n <ZoomInput value={zoom} onChange={onZoomChange} onFit={onFit} labels={labels} />\n {/* zoom group → next action group divider(action-bar canonical;v11 升級成 Separator\n 元件,對齊 separator.spec.md「consumer 手動放置 toolbar 群組分隔線 = 用 Separator」)*/}\n <Separator orientation=\"vertical\" className=\"h-6 mx-1\" />\n </>\n )}\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Info}\n aria-label={infoOpen ? labels.infoToggleCollapse : labels.infoToggleExpand}\n pressed={infoOpen}\n onClick={onInfoToggle}\n />\n {allowDownload && (\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Download}\n aria-label={labels.download}\n onClick={onDownload}\n />\n )}\n {/* action-bar canonical:dismiss 前加分隔線跟其他動作分群(info/download = action group,\n close = dismiss group;v11 升級成 Separator,對齊 separator.spec.md canonical)*/}\n <Separator orientation=\"vertical\" className=\"h-6 mx-1\" />\n {/* Close X 走 dismiss canonical(`<Button iconOnly dismiss />`)——對齊 CLAUDE.md\n `button.spec.md`「Dismiss 視覺類」+ `patterns/element-anatomy/item-anatomy.spec.md`\n 「Dismiss canonical」:chrome corner close X = Button dismiss,不是 Inline Action。 */}\n <Button\n iconOnly\n dismiss\n size=\"sm\"\n data-dismiss\n startIcon={XIcon}\n aria-label={labels.close}\n onClick={onClose}\n />\n </div>\n </ChromeHeader>\n )\n}\n\n// ─── InfoPanel ────────────────────────────────────────────────────────────────\n\ninterface InfoPanelProps {\n file: FileInfo\n readOnly: boolean\n onDescriptionChange?: (fileId: string, description: string) => void\n onClose: () => void\n labels: Required<FileViewerLabels>\n}\n\nfunction formatBytes(bytes: number | undefined): string | undefined {\n if (bytes == null) return undefined\n if (bytes < 1024) return `${bytes} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`\n}\n\n// React.memo(2026-07-06 D3):同 Filmstrip——wheel-zoom 每 tick 全 chrome re-render,InfoPanel\n// 開啟時(Field/Textarea/DescriptionList subtree)白付。onClose 走 shell handleInfoClose\n// useCallback;file(files[activeIndex] identity 穩定)/readOnly/onDescriptionChange/labels 本已穩定。\nconst InfoPanel = React.memo(function InfoPanel({\n file,\n readOnly,\n onDescriptionChange,\n onClose,\n labels,\n}: InfoPanelProps) {\n const [draft, setDraft] = React.useState(file.description ?? '')\n\n React.useEffect(() => {\n setDraft(file.description ?? '')\n }, [file.id, file.description])\n\n const commit = () => {\n if (readOnly) return\n if (draft !== (file.description ?? '')) {\n onDescriptionChange?.(file.id, draft)\n }\n }\n\n const sizeText = formatBytes(file.size)\n\n return (\n <aside\n className={cn(\n // Chrome — bg-surface-raised 同 Toolbar / Filmstrip(token semantic「遮蓋型浮層」)\n 'w-80 shrink-0 flex flex-col bg-surface-raised border-l border-divider',\n 'h-full',\n )}\n aria-label={labels.detailPanel}\n >\n {/* Panel header — 與 Toolbar 同 ChromeHeader,繼承 page density(2026-06-15:移除 lockDensity,\n 修 header(原 px-loose@lg=24)與 body(px-loose@page=16)左緣不對齊 = 圖二 bug;全 surface 同密度)。 */}\n <ChromeHeader className=\"justify-between\">\n <h3 className=\"text-body-lg font-medium text-foreground\">{labels.detailsHeading}</h3>\n {/* InfoPanel close 走 dismiss canonical `<Button iconOnly dismiss />`,對齊 button.spec.md\n 「Dismiss 視覺類」+ inline-action.spec.md「Dismiss canonical — X close only」。 */}\n <Button\n iconOnly\n dismiss\n size=\"sm\"\n data-dismiss\n startIcon={XIcon}\n aria-label={labels.detailPanelClose}\n onClick={onClose}\n />\n </ChromeHeader>\n\n {/* Panel body — header(shrink-0)上常駐 + body 走 ScrollArea(高度小時內容可捲動)。\n padding 對齊 layoutSpace v6 規則 4「bounded region → 容器底(無 action buttons)= loose」。\n gap 對齊 v6 規則 3「跨範疇 parallel = loose」(說明 vs 檔案資訊兩個獨立 functional sections,\n 屬「跨範疇 + 不相關」)— 從 gap-4 寫死改為 token-aware loose。 */}\n <ScrollArea className=\"flex-1 min-h-0\">\n <div className={cn(\n 'flex flex-col gap-[var(--layout-space-loose)]',\n 'px-[var(--layout-space-loose)]',\n 'pt-[var(--layout-space-tight)] pb-[var(--layout-space-loose)]',\n )}>\n {/* 說明 — 用 DS Field + FieldLabel + Textarea(2026-04-20 B12 決策:\n FileViewer 一律消費 DS Field 家族,不手刻 `<span>label` + raw control) */}\n <Field>\n <FieldLabel>說明</FieldLabel>\n <Textarea\n value={draft}\n onChange={(e) => setDraft(e.target.value)}\n onBlur={commit}\n readOnly={readOnly}\n placeholder={readOnly ? labels.descriptionPlaceholderReadOnly : labels.descriptionPlaceholderEdit}\n rows={5}\n />\n </Field>\n\n {/* 檔案資訊 — 用 DS DescriptionList horizontal + divided(Google Drive /\n Notion file info panel 模式):\n - section header 用 FieldLabel 同款 typography 保視覺一致\n - DescriptionList direction=\"horizontal\" divided 提供 row 下底線\n 對齊格線,key 長度不一也易讀\n - 不再手刻 dl/dt/dd — canonical 由 DS primitive own */}\n {/* heading → first-item gap = item → item gap(Gestalt proximity,見 description-list.spec.md) */}\n <div className=\"flex flex-col gap-[var(--layout-space-tight)]\">\n <span className=\"text-body font-normal text-foreground\">{labels.fileInfoHeading}</span>\n <DescriptionList direction=\"horizontal\" divided>\n <DescriptionItem label=\"檔名\">{file.name}</DescriptionItem>\n <DescriptionItem label=\"類型\">{file.mimeType || '—'}</DescriptionItem>\n {sizeText && (\n <DescriptionItem label=\"大小\">\n <span className=\"tabular-nums\">{sizeText}</span>\n </DescriptionItem>\n )}\n {file.metadata &&\n Object.entries(file.metadata).map(([k, v]) => (\n <DescriptionItem key={k} label={k}>{String(v)}</DescriptionItem>\n ))}\n </DescriptionList>\n </div>\n </div>\n </ScrollArea>\n </aside>\n )\n})\n\n// ─── Filmstrip ────────────────────────────────────────────────────────────────\n\ninterface FilmstripProps {\n files: FileInfo[]\n activeIndex: number\n onSelect: (index: number) => void\n labels: Pick<Required<FileViewerLabels>, 'filmstripLabel'>\n}\n\n// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding\nconst THUMB_SIZE = 64 // px, 固定\n\n// React.memo(2026-07-06 D3):wheel-zoom 每 tick(trackpad 60Hz+)setZoom → shell 全 chrome\n// re-render,Filmstrip(N thumbs × cn + buildFadeMask)是最貴的無關 subtree。props 已全穩定\n// (files=prop / activeIndex=primitive / onSelect=setIndex useCallback / labels=useMemo)→ memo 真命中。\nconst Filmstrip = React.memo(function Filmstrip({ files, activeIndex, onSelect, labels }: FilmstripProps) {\n const { scrollRef, atStart, atEnd, canScroll } = useScrollEdges<HTMLDivElement>()\n const scrollByPage = useScrollByPage(scrollRef)\n const maskImage = buildFadeMask({ canScroll, atStart, atEnd, reserveArrowWidth: ARROW_BUTTON_WIDTH }) // 2026-07-04:消 SSOT 硬寫(spec 禁止重新定義 ARROW_BUTTON_WIDTH)\n\n // 切換當前檔案時,自動 scroll 讓 active thumb 可見\n React.useEffect(() => {\n const el = scrollRef.current\n if (!el) return\n const active = el.querySelector<HTMLButtonElement>(`[data-thumb-index=\"${activeIndex}\"]`)\n active?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })\n }, [activeIndex, scrollRef])\n\n return (\n <div\n className={cn(\n // Chrome — bg-surface-raised 同 Toolbar / InfoPanel(token semantic「遮蓋型浮層」)\n 'relative shrink-0 h-24 bg-surface-raised border-t border-divider',\n 'flex items-center',\n 'px-[var(--layout-space-loose)]',\n )}\n >\n {canScroll && !atStart && (\n <OverflowScrollArrow direction=\"left\" onClick={() => scrollByPage('left')} />\n )}\n <div\n ref={scrollRef}\n className={cn(\n 'flex items-center',\n // 刻意隱藏 native scrollbar + 用 fade-mask(horizontal-overflow pattern)\n 'scrollbar-none overflow-x-auto overflow-y-hidden h-full py-2',\n 'w-full',\n )}\n style={{\n maskImage,\n WebkitMaskImage: maskImage,\n }}\n >\n {/* 內層 wrapper:mx-auto 讓 thumbs 在少量時水平置中,多量溢出時 mx-auto = 0 自然轉 scroll。\n gap-[var(--layout-space-tight)] 走 DS density-aware token(不用 raw gap-1)——\n 世界級 idiom:Google Drive / Dropbox / Notion file preview 的 filmstrip 都是\n 少量置中 / 多量靠 start scroll。\n ARIA role=\"group\"(非 tablist):filmstrip 是「選圖導航」不是「切換 N 個 tabpanel」——\n lightbox 縮圖膠卷只有單一 viewport,不符 W3C tabs pattern(tab MUST 對應自己的 tabpanel + roving tabindex)。\n 對齊 APG grouped-carousel + PhotoSwipe / codeaccessible.com lightbox 共識:group + button + aria-current。 */}\n <div\n role=\"group\"\n aria-label={labels.filmstripLabel}\n className=\"flex items-center gap-[var(--layout-space-tight)] mx-auto shrink-0\"\n >\n {files.map((file, i) => {\n const active = i === activeIndex\n const isImage = canRenderImage(file)\n const ext = file.name.split('.').pop()?.toUpperCase() ?? '檔'\n return (\n <button\n key={file.id}\n type=\"button\"\n aria-current={active ? 'true' : undefined}\n aria-label={`${i + 1} / ${files.length}:${file.name}`}\n data-thumb-index={i}\n onClick={() => onSelect(i)}\n className={cn(\n 'shrink-0 rounded-md bg-muted overflow-hidden',\n 'outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'transition-shadow duration-150',\n active\n ? 'ring-2 ring-primary'\n : 'ring-1 ring-border hover:ring-border-hover',\n )}\n style={{ width: THUMB_SIZE, height: THUMB_SIZE }}\n >\n <AspectRatio ratio={1} className=\"w-full h-full\">\n {isImage ? (\n <img\n src={file.url}\n alt=\"\"\n aria-hidden\n className=\"w-full h-full object-cover\"\n draggable={false}\n />\n ) : (\n <div className=\"w-full h-full flex flex-col items-center justify-center gap-0.5\">\n <FileText size={20} className=\"text-fg-muted\" aria-hidden />\n <span className=\"text-footnote text-fg-muted font-medium\">{ext}</span>\n </div>\n )}\n </AspectRatio>\n </button>\n )\n })}\n </div>\n </div>\n {canScroll && !atEnd && (\n <OverflowScrollArrow direction=\"right\" onClick={() => scrollByPage('right')} />\n )}\n </div>\n )\n})\n\n// ─── FileViewer (shell) ───────────────────────────────────────────────────────\n\n/**\n * i18n-able labels for FileViewer chrome / controls.\n * All keys are optional — defaults are CJK (see `DEFAULT_LABELS`).\n * Consumer typically spreads partial override:\n * `<FileViewer labels={{ close: 'Close', download: 'Download' }} />`\n */\n// code-quality-allow: dead-export — public API surface — consumer-exposed for future use\nexport interface FileViewerLabels {\n /** Zoom input ARIA label */\n zoomInput?: string\n /** Zoom menu trigger ARIA label */\n zoomMenu?: string\n /** Info panel toggle button — shown when panel is OPEN */\n infoToggleCollapse?: string\n /** Info panel toggle button — shown when panel is CLOSED */\n infoToggleExpand?: string\n /** Download button ARIA label */\n download?: string\n /** Close viewer button ARIA label */\n close?: string\n /** InfoPanel outer aside ARIA label */\n detailPanel?: string\n /** InfoPanel heading text */\n detailsHeading?: string\n /** InfoPanel close button ARIA label */\n detailPanelClose?: string\n /** Description textarea placeholder (readOnly) */\n descriptionPlaceholderReadOnly?: string\n /** Description textarea placeholder (editable) */\n descriptionPlaceholderEdit?: string\n /** Detail section — file info section heading */\n fileInfoHeading?: string\n /** Filmstrip group ARIA label(role=\"group\" + thumb button + aria-current,非 tablist)*/\n filmstripLabel?: string\n /** Previous-file nav button ARIA label */\n previousFile?: string\n /** Next-file nav button ARIA label */\n nextFile?: string\n}\n\n// i18n-allow: DS defaults;consumer override via `labels` prop\nconst DEFAULT_LABELS: Required<FileViewerLabels> = {\n zoomInput: '縮放比例',\n zoomMenu: '開啟縮放選單',\n infoToggleCollapse: '收合詳細資訊面板',\n infoToggleExpand: '展開詳細資訊面板',\n download: '下載檔案',\n close: '關閉檢視器',\n detailPanel: '檔案詳細資訊',\n detailsHeading: '詳細資訊',\n detailPanelClose: '關閉詳細資訊',\n descriptionPlaceholderReadOnly: '尚無說明',\n descriptionPlaceholderEdit: '為這個檔案加上說明…',\n fileInfoHeading: '檔案資訊',\n filmstripLabel: '檔案佇列',\n previousFile: '上一個檔案',\n nextFile: '下一個檔案',\n}\n\nexport interface FileViewerProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,\n 'onOpenChange'\n > {\n files: FileInfo[]\n initialIndex?: number\n /** Controlled open state。與 `defaultOpen` 二擇一。 */\n open?: boolean\n /** Uncontrolled open 預設(2026-04-25 加,對齊 Dialog/Sheet/Popover dual-mode canonical)。 */\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n /** 當前索引(controlled);consumer 想自己控制 active file 時傳。不傳則 shell 管理。 */\n index?: number\n onIndexChange?: (index: number) => void\n /** 當前檔案 description 變化。consumer 負責持久化。readOnly 為 true 時不觸發。 */\n onDescriptionChange?: (fileId: string, description: string) => void\n /** true → InfoPanel 的 description textarea 為 readOnly。預設 false。 */\n readOnly?: boolean\n /** 顯示底部 filmstrip。預設 false;files.length < 2 時自動隱藏。 */\n showFilmstrip?: boolean\n /** 是否提供 download 按鈕。預設 true。 */\n allowDownload?: boolean\n /** 自訂 download 行為;未傳則用 anchor download attribute。 */\n onDownload?: (file: FileInfo) => void\n /** i18n labels override. Partial — merged with DS defaults. */\n labels?: FileViewerLabels\n}\n\nconst FileViewer = React.forwardRef<HTMLDivElement, FileViewerProps>(function FileViewer({\n files,\n initialIndex = 0,\n open: openProp,\n defaultOpen,\n onOpenChange,\n index: indexProp,\n onIndexChange,\n onDescriptionChange,\n readOnly = false,\n showFilmstrip = false,\n allowDownload = true,\n onDownload,\n labels: labelsOverride,\n ...props\n}, ref) {\n const labels = React.useMemo(\n () => ({ ...DEFAULT_LABELS, ...labelsOverride }) satisfies Required<FileViewerLabels>,\n [labelsOverride],\n )\n // Open:受控/非受控雙模式鏡像(消費 DS useControllable;select-menu.tsx 2026-06-11 同修先例)。\n // 原本 keyboard shortcuts effect 與 index-reset effect 直接讀 `open` prop ——\n // uncontrolled(defaultOpen)場景 open === undefined,兩 effect 永遠不啟動(快捷鍵全失效\n // + 重開不重置 index),Toolbar X / backdrop click 的 onOpenChange?.(false) 也是 no-op。\n // 鏡像後 effect / close 都走 mirror:controlled 時純 passthrough,\n // uncontrolled 時 internal state 為準 + onOpenChange 僅通知。\n const [open, setOpen] = useControllable<boolean>({\n value: openProp,\n defaultValue: defaultOpen ?? false,\n onChange: onOpenChange,\n })\n\n // Index:uncontrolled fallback\n const [internalIndex, setInternalIndex] = React.useState(initialIndex)\n const activeIndex = indexProp ?? internalIndex\n\n const setIndex = React.useCallback(\n (next: number) => {\n const clamped = Math.max(0, Math.min(files.length - 1, next))\n if (indexProp === undefined) setInternalIndex(clamped)\n onIndexChange?.(clamped)\n },\n [files.length, indexProp, onIndexChange],\n )\n\n // 開啟時若 uncontrolled,重置為 initialIndex\n React.useEffect(() => {\n if (open && indexProp === undefined) {\n setInternalIndex(Math.max(0, Math.min(files.length - 1, initialIndex)))\n }\n }, [open, initialIndex, files.length, indexProp])\n\n // Info panel open state(shell own)\n const [infoOpen, setInfoOpen] = React.useState(false)\n // 2026-07-06 D3 perf:抽 useCallback 穩定 identity——InfoPanel 上了 React.memo(見上),\n // inline arrow 每 render 新 identity 會打穿 memo shallow-compare。\n const handleInfoToggle = React.useCallback(() => setInfoOpen((o) => !o), [])\n const handleInfoClose = React.useCallback(() => setInfoOpen(false), [])\n\n // Zoom state(shell own,renderer 消費 + 回報)\n const [zoom, setZoom] = React.useState(100)\n // 切換檔案時不再 setZoom(100)— 把「下一張該怎麼初始化 zoom」的決定權交給 renderer:\n // image-renderer 自己 watch file.url change → reset loaded → onLoad → 重 fit-page。\n // 原本 setZoom(100) 在 cache 命中(onLoad 沒 fire)時會卡 100% 不 fit(user 抓的 bug)。\n\n // Fit request(shell → renderer 指令;nonce 遞增讓重複同 fit 也觸發 renderer)\n const [fitRequest, setFitRequest] = React.useState<{ fit: ZoomFit; nonce: number } | null>(null)\n\n // Renderer capabilities(mount 時 renderer emit)\n const [capabilities, setCapabilities] = React.useState<FileRendererCapabilities>({\n zoom: false,\n })\n\n const file = files[activeIndex]\n const Renderer = file ? resolveRenderer(file) : null\n\n // Fit-to-* 下指令給 renderer,renderer 算 container/image 比例後透過 onZoomChange 回報\n const handleFit = React.useCallback((fit: ZoomFit) => {\n setFitRequest((prev) => ({ fit, nonce: (prev?.nonce ?? 0) + 1 }))\n }, [])\n\n // Download handler\n const handleDownload = React.useCallback(() => {\n if (!file) return\n if (onDownload) {\n onDownload(file)\n return\n }\n // 預設:anchor download(同源檔案有效;跨域由 consumer 提供 onDownload)\n const a = document.createElement('a')\n a.href = file.url\n a.download = file.name\n a.target = '_blank'\n a.rel = 'noopener'\n document.body.appendChild(a)\n a.click()\n document.body.removeChild(a)\n }, [file, onDownload])\n\n // Keyboard shortcuts(focus 在 input / textarea 時不觸發)\n React.useEffect(() => {\n if (!open) return\n const handler = (e: KeyboardEvent) => {\n const target = e.target as HTMLElement | null\n const tag = target?.tagName\n if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return\n\n if (e.key === 'ArrowLeft' && files.length > 1) {\n e.preventDefault()\n setIndex(activeIndex - 1)\n } else if (e.key === 'ArrowRight' && files.length > 1) {\n e.preventDefault()\n setIndex(activeIndex + 1)\n } else if (e.key === '+' || e.key === '=') {\n if (capabilities.zoom) {\n e.preventDefault()\n setZoom((z) => nextZoomIn(z))\n }\n } else if (e.key === '-') {\n if (capabilities.zoom) {\n e.preventDefault()\n setZoom((z) => nextZoomOut(z))\n }\n } else if (e.key === '0') {\n if (capabilities.zoom) {\n e.preventDefault()\n setZoom(100)\n }\n } else if (e.key === 'f' || e.key === 'F') {\n if (capabilities.zoom) {\n e.preventDefault()\n handleFit('fit-page')\n }\n } else if (e.key === 'i' || e.key === 'I') {\n e.preventDefault()\n setInfoOpen((o) => !o)\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [open, activeIndex, files.length, setIndex, capabilities.zoom, handleFit])\n\n // Arrows idle auto-hide(世界級 lightbox canonical:Google Photos / Dropbox / PhotoSwipe)\n // 滑鼠移入 viewport → 顯示箭頭;持續 2.5 秒無 mouse move → 自動淡出(對齊世界級行為)\n const [armVisible, setArmVisible] = React.useState(false)\n const idleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const handleViewportMouseMove = React.useCallback(() => {\n setArmVisible(true)\n if (idleTimerRef.current) clearTimeout(idleTimerRef.current)\n idleTimerRef.current = setTimeout(() => setArmVisible(false), 2500)\n }, [])\n const handleViewportMouseLeave = React.useCallback(() => {\n setArmVisible(false)\n if (idleTimerRef.current) clearTimeout(idleTimerRef.current)\n }, [])\n React.useEffect(() => () => {\n if (idleTimerRef.current) clearTimeout(idleTimerRef.current)\n }, [])\n\n if (!file || !Renderer) {\n // files 為空或 index 超界 — 不渲染\n return null\n }\n\n const showFilmstripResolved = showFilmstrip && files.length > 1\n const showArrows = files.length > 1\n\n // Root 永遠餵 mirror(Radix 視角 controlled);uncontrolled 的 defaultOpen 由\n // useControllable internal state 承載,Radix Esc / dismiss 經 setOpen 同步回 mirror。\n return (\n <DialogPrimitive.Root open={open} onOpenChange={setOpen}>\n <DialogPrimitive.Portal>\n {/* Overlay — FileViewer 固定深色氛圍,與 Dialog 共用 bg-overlay。\n **data-theme=\"dark\"**(2026-04-30):Overlay 在 Portal 內、是 Content 的 sibling,\n 不繼承 Content 內層的 dark 主題 → `--overlay` 默認 resolve 成 light theme α45 黑。\n FileViewer 永遠 dark(line 899 outer),mask 也須 dark token = α65 黑(更深)\n 才語意一致。同 DropdownMenuContent Portal 處理(line 245)。 */}\n <DialogPrimitive.Overlay\n data-theme=\"dark\"\n className={cn(\n 'fixed inset-0 z-50 bg-overlay',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n )}\n />\n <DialogPrimitive.Content\n ref={ref}\n className={cn(\n // Edge-to-edge fullscreen,無 inset / 無 radius(與一般 Dialog 差別的所在)\n 'fixed inset-0 z-50 outline-none',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n )}\n // 避免 Radix 自動把焦點送進 Content 的第一個 tabbable —— 我們要留給 viewport\n onOpenAutoFocus={(e) => e.preventDefault()}\n {...props}\n >\n {/* 鎖 dark subtree。Density 繼承 page(不另設 data-density)。\n header 高度透過 `--chrome-header-height` 自動 density-aware(md=48 / lg=56)。\n ── Q1 mask 透明度(2026-04-30)──\n outer **不**設 bg → Overlay(`bg-overlay` α45/α65)透出 image 周圍區域,\n 對齊 Notion / Dropbox / Slack lightbox idiom 跟 Dialog mask 同 token 一致。\n chrome(Toolbar / Filmstrip / InfoPanel)各自 `bg-surface-raised` opaque dark\n (對齊 Apple Photos / Drive lightbox 派 — chrome opaque vs mask 半透明,\n 清楚區分 backdrop click 區 vs 互動區)。\n **不**用 bg-surface — dark mode `--surface = white α8` 半透明,outer 透明時\n 無 dark backdrop 撐 → 視覺洗白。 */}\n <div\n data-theme=\"dark\"\n className=\"w-full h-full flex flex-col text-foreground\"\n >\n {/* Accessible title — 視覺隱藏但 screen reader 讀得到 */}\n <DialogPrimitive.Title className=\"sr-only\">\n 檔案檢視器:{file.name}\n </DialogPrimitive.Title>\n\n <Toolbar\n file={file}\n capabilities={capabilities}\n zoom={zoom}\n onZoomChange={setZoom}\n onFit={handleFit}\n infoOpen={infoOpen}\n onInfoToggle={handleInfoToggle}\n onDownload={handleDownload}\n allowDownload={allowDownload}\n onClose={() => setOpen(false)}\n labels={labels}\n />\n\n {/* 主區:Viewport + 可選 InfoPanel(右側)\n Arrows visibility = armVisible(state)控制:mouse move 顯示 / 2.5s idle 隱藏 / mouse leave 立即隱藏\n 對齊 Google Photos / Dropbox lightbox / PhotoSwipe world-class canonical */}\n <div className=\"flex-1 min-h-0 flex\">\n <div\n className=\"relative flex-1 min-w-0\"\n onMouseMove={handleViewportMouseMove}\n onMouseLeave={handleViewportMouseLeave}\n // Backdrop click-to-close(對齊 Google Drive / Dropbox lightbox / Apple Photos canonical):\n // 點擊 image 周圍的暗色 backdrop 區關閉,跟 modal mask 同 idiom。\n //\n // 為何 geometric check 而非 closest('img')?\n // react-zoom-pan-pinch 的 TransformComponent 是 wrapper div 蓋在 image 之上(absorb\n // pan/zoom events),click target 是該 wrapper div 不是 <img>。closest('img') 檢查\n // ancestor 永遠 false。改 geometric check:看 click 座標是否落在 <img> 視覺 rect 內。\n onClick={(e) => {\n const t = e.target as HTMLElement\n // 互動元素(side arrows / chrome buttons 透過冒泡)→ 不關\n if (t.closest('button, [role=\"button\"]')) return\n // 點到 image 視覺範圍 → 不關(image 本體 click ≠ close)\n const img = e.currentTarget.querySelector('img')\n if (img) {\n const r = img.getBoundingClientRect()\n if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return\n }\n // 否則 = 點到 backdrop(outer 不設 bg,半透明 bg-overlay 透出 — 見 Q1 註解)→ close\n setOpen(false)\n }}\n >\n {showArrows && activeIndex > 0 && (\n <div\n className={cn(\n 'absolute left-[var(--layout-space-loose)] top-1/2 -translate-y-1/2 z-10',\n 'transition-opacity duration-150',\n // armVisible state 控制,或 focus-within 時 a11y 強制顯示\n armVisible ? 'opacity-100' : 'opacity-0 pointer-events-none',\n 'focus-within:opacity-100 focus-within:pointer-events-auto',\n )}\n >\n <Button\n variant=\"text\"\n size=\"md\"\n iconOnly\n startIcon={ChevronLeft}\n aria-label={labels.previousFile}\n onClick={() => setIndex(activeIndex - 1)}\n />\n </div>\n )}\n <div className=\"w-full h-full\">\n {/* Suspense 兜 React.lazy renderer(ImageRenderer)chunk 載入。fallback=null\n = 既有 loading 行為——spec「Loading」段:shell 不提供全頁 loading state,\n loading 由 renderer 自理;chunk 載入期間的空 viewport 與 <img> 載入期間視覺一致。 */}\n <React.Suspense fallback={null}>\n <Renderer.component\n file={file}\n zoom={zoom}\n onZoomChange={setZoom}\n fitRequest={fitRequest}\n onCapabilitiesChange={setCapabilities}\n />\n </React.Suspense>\n </div>\n {showArrows && activeIndex < files.length - 1 && (\n <div\n className={cn(\n 'absolute right-[var(--layout-space-loose)] top-1/2 -translate-y-1/2 z-10',\n 'transition-opacity duration-150',\n armVisible ? 'opacity-100' : 'opacity-0 pointer-events-none',\n 'focus-within:opacity-100 focus-within:pointer-events-auto',\n )}\n >\n <Button\n variant=\"text\"\n size=\"md\"\n iconOnly\n startIcon={ChevronRight}\n aria-label={labels.nextFile}\n onClick={() => setIndex(activeIndex + 1)}\n />\n </div>\n )}\n </div>\n {infoOpen && (\n <InfoPanel\n file={file}\n readOnly={readOnly}\n onDescriptionChange={onDescriptionChange}\n onClose={handleInfoClose}\n labels={labels}\n />\n )}\n </div>\n\n {showFilmstripResolved && (\n <Filmstrip\n files={files}\n activeIndex={activeIndex}\n onSelect={setIndex}\n labels={labels}\n />\n )}\n </div>\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n </DialogPrimitive.Root>\n )\n})\nFileViewer.displayName = 'FileViewer'\n\n// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)\n// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs\nexport const fileViewerMeta = {\n component: 'FileViewer',\n family: null, // non-family composite / overlay / layout\n variants: {\n\n },\n sizes: {\n\n },\n // 'selected' = 縮圖列當前頁持續選中(bg-neutral-selected);'active' 移除 — 全檔無按壓視覺\n // (工具列按壓屬內嵌 Button meta;2026-07-07 詞彙統一 DS-wide 交叉掃補修)。\n states: ['default', 'hover', 'selected', 'focus-visible', 'disabled'],\n tokens: {\n bg: ['bg-muted', 'bg-neutral-selected', 'bg-overlay', 'bg-surface-raised'],\n fg: ['text-fg-muted', 'text-foreground'],\n ring: ['ring-primary', 'ring-ring'],\n },\n} as const\n\nexport { FileViewer }\nexport type { FileInfo, FileRenderer, FileRendererCapabilities, FileRendererProps } from './file-viewer-types'\n"],"names":["FileIcon","XIcon","InfoPanel","Filmstrip","FileViewer"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"file-viewer.js","sources":["../../../src/components/FileViewer/file-viewer.tsx"],"sourcesContent":["// @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.\n// code-quality-allow: file-size — composite 拼裝(Toolbar / ZoomInput / InfoPanel / Filmstrip + Dialog shell + renderer registry);拆檔會把 useState/useEffect/key handler 跨檔同步過於複雜\nimport * as React from 'react'\nimport * as DialogPrimitive from '@radix-ui/react-dialog'\nimport {\n X as XIcon,\n Download,\n Info,\n ChevronLeft,\n ChevronRight,\n ChevronDown,\n Plus,\n Minus,\n File as FileIcon,\n FileText,\n} from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport { OVERLAY_SIDE_OFFSET } from '@/design-system/tokens/elevation/overlay-geometry'\nimport { Button } from '@/design-system/components/Button/button'\nimport { Separator } from '@/design-system/components/Separator/separator'\nimport { Input } from '@/design-system/components/Input/input'\nimport { Empty } from '@/design-system/components/Empty/empty'\nimport { AspectRatio } from '@/design-system/components/AspectRatio/aspect-ratio'\nimport { Textarea } from '@/design-system/components/Textarea/textarea'\nimport { Field, FieldLabel } from '@/design-system/components/Field/field'\nimport { DescriptionList, DescriptionItem } from '@/design-system/components/DescriptionList/description-list'\nimport { ItemInlineActionButton } from '@/design-system/patterns/element-anatomy/item-anatomy'\nimport { ChromeHeader } from '@/design-system/patterns/header-canonical/chrome-header'\nimport { ScrollArea } from '@/design-system/components/ScrollArea/scroll-area'\nimport {\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n} from '@/design-system/components/DropdownMenu/dropdown-menu'\nimport { ARROW_BUTTON_WIDTH,\n useScrollEdges,\n useScrollByPage,\n buildFadeMask,\n OverflowScrollArrow,\n} from '@/design-system/patterns/horizontal-overflow/horizontal-overflow'\nimport { useControllable } from '@/design-system/hooks/use-controllable'\nimport { canRenderImage } from './can-render-image'\nimport type {\n FileInfo,\n FileRenderer,\n FileRendererCapabilities,\n FileRendererProps,\n} from './file-viewer-types'\nimport { surfaceMotion } from \"@/design-system/tokens/motion/overlay-motion\"\n\n/**\n * FileViewer — 可延伸的網頁檔案 preview shell(modal fullscreen)\n *\n * ── 定位 ──\n * 公開、composite 元件。consumer 傳 `files`,FileViewer 處理 overlay / toolbar /\n * keyboard / filmstrip / info panel 一切 chrome;檔案本體由 renderer registry\n * 按 file MIME 決定誰渲染(MVP 內建 ImageRenderer + FallbackRenderer)。\n *\n * ── 實作基礎 ──\n * 自建 composite,消費 DS primitives:\n * - Radix DialogPrimitive(焦點 trap / Esc / modal 隔離靠 hideOthers aria-hidden — react-dialog@1.1.x 不輸出 aria-modal,保有 shadcn 結構優勢)\n * - `<Empty>` / `<Button>` / `<Input size=\"sm\" autoWidth>`(ZoomInput,default variant)/ `<AspectRatio>` / `<Textarea>` / `<DropdownMenu>`\n * - `patterns/horizontal-overflow`(filmstrip 溢出捲動)\n * 不用 DS 的 `<Dialog>` wrapper:因為 FileViewer 需要 edge-to-edge fullscreen\n * (無 viewport inset / 無 rounded-lg / 無 maxWidth),Dialog 的這些預設都要覆寫。\n * 直接消費 Radix primitive 讓 shell 擁有完整 layout 控制權。\n *\n * ── Layout Family ──\n * 非 Family 1/2/3/4 — composite / multi-region(Toolbar / Viewport / Filmstrip +\n * 可選 InfoPanel)。見 `file-viewer.spec.md`「Layout Family」段。\n *\n * ── Extensibility ──\n * `registerFileRenderer(renderer)` 註冊新 renderer;shell 按註冊順序 iterate,\n * 第一個 `canRender(file)` 回 true 的渲染。FallbackRenderer 永遠兜底(未知檔案\n * 類型顯示 icon + 檔名 + download)。\n */\n\n// ─── Renderer Registry ────────────────────────────────────────────────────────\n\n/**\n * Fallback renderer — 無 renderer 能處理時兜底。\n * 顯示 Empty 佈局:icon + 檔名 + 「請下載檢視」提示。\n */\nconst FallbackRenderer: React.FC<FileRendererProps> = ({ file, onCapabilitiesChange }) => {\n // Contract(file-viewer-types.ts `onCapabilitiesChange` doc):「第一個 renderer mount\n // 時必須 emit」。fallback 不支援 zoom → emit { zoom: false };不 emit 的話 shell 會沿用\n // 上一個 renderer 的 capability(image → 未知檔切換時 toolbar 殘留 zoom controls,\n // `+`/`-`/`0`/`F` 快捷鍵也會對不支援 zoom 的檔案生效)。同 image-renderer.tsx mount emit pattern。\n React.useEffect(() => {\n onCapabilitiesChange({ zoom: false })\n }, [onCapabilitiesChange])\n\n return (\n <div className=\"w-full h-full flex items-center justify-center p-8\">\n <Empty\n icon={FileText}\n title={file.name}\n description={`無法在瀏覽器中預覽此檔案類型(${file.mimeType || 'unknown'})。請下載後檢視。`}\n />\n </div>\n )\n}\n\nconst fallbackRenderer: FileRenderer = {\n id: 'fallback',\n canRender: () => true,\n component: FallbackRenderer,\n}\n\n// 2026-07-06 D3 bundle fix:ImageRenderer 改 React.lazy 動態 import——FileViewer 是\n// rare-use modal overlay,react-zoom-pan-pinch(~12KB gz)不該在 viewer 從未打開時就進\n// consumer 首屏 bundle。canRenderImage(registry 判斷 + Filmstrip 分流需同步呼叫)已拆至\n// ./can-render-image 保持靜態 import。\nconst LazyImageRenderer = React.lazy(() =>\n import('./image-renderer').then((m) => ({ default: m.ImageRenderer })),\n)\n\nconst imageRenderer: FileRenderer = {\n id: 'image',\n canRender: canRenderImage,\n component: LazyImageRenderer,\n}\n\n// Registry 是 module-singleton:新 renderer 透過 registerFileRenderer 加入。\n// Fallback 永遠最後(兜底),因此用陣列第二段存放。\nconst userRegistered: FileRenderer[] = []\n\nexport function registerFileRenderer(renderer: FileRenderer): void {\n // 去重:同 id 則覆寫\n const existingIdx = userRegistered.findIndex((r) => r.id === renderer.id)\n if (existingIdx >= 0) {\n userRegistered[existingIdx] = renderer\n } else {\n userRegistered.push(renderer)\n }\n}\n\nfunction resolveRenderer(file: FileInfo): FileRenderer {\n // 先查 user registered,再 built-in,最後 fallback\n for (const r of userRegistered) {\n if (r.canRender(file)) return r\n }\n if (imageRenderer.canRender(file)) return imageRenderer\n return fallbackRenderer\n}\n\n// ─── Zoom presets ─────────────────────────────────────────────────────────────\n\ntype ZoomFit = 'fit-width' | 'fit-page'\n\nconst ZOOM_PRESETS: number[] = [10, 25, 50, 75, 100, 125, 150, 200, 400]\n// i18n-allow-block: DS defaults for zoom fit menu;consumer override via `labels.zoomFitOptions` (future) or fork\nconst ZOOM_FIT_OPTIONS: { value: ZoomFit; label: string }[] = [\n { value: 'fit-width', label: 'Fit to width' },\n { value: 'fit-page', label: 'Fit to page' },\n]\n\nfunction nextZoomIn(current: number): number {\n for (const p of ZOOM_PRESETS) {\n if (p > current) return p\n }\n return ZOOM_PRESETS[ZOOM_PRESETS.length - 1]\n}\nfunction nextZoomOut(current: number): number {\n for (let i = ZOOM_PRESETS.length - 1; i >= 0; i--) {\n if (ZOOM_PRESETS[i] < current) return ZOOM_PRESETS[i]\n }\n return ZOOM_PRESETS[0]\n}\n\n// ─── ZoomInput ────────────────────────────────────────────────────────────────\n\ninterface ZoomInputProps {\n value: number\n onChange: (next: number) => void\n onFit: (fit: ZoomFit) => void\n labels: Pick<Required<FileViewerLabels>, 'zoomInput' | 'zoomMenu'>\n}\n\n/**\n * ZoomInput — [−] [% input(default variant + autoWidth)with ⌄ menu trigger] [+]\n *\n * 世界級對照:Figma zoom control / Adobe Acrobat / Google Slides zoom。\n *\n * ── 消費 DS primitive ──\n * - `<Button>` iconOnly size=\"sm\" 作 ±按鈕\n * - `<Input size=\"sm\" autoWidth>` 作 %輸入(Toolbar inline editing canonical;autoWidth 隨文字寬)\n * - Input `endSlot` escape hatch 包 `<DropdownMenuTrigger asChild>` + chevron 觸發 DropdownMenu\n * - `<DropdownMenu>` 作 preset + fit 選單(取代原先 Popover + 手刻 button list)\n *\n * ── 為什麼 inline(不抽獨立 primitive)──\n * 目前只 FileViewer 消費;MVP 階段遵循 YAGNI。當 PDF / Video viewer 也需要相同\n * primitive 時,再依「建立前必查既有 pattern」原則從 FileViewer 抽出升級。\n */\nconst ZoomInput: React.FC<ZoomInputProps> = ({ value, onChange, onFit, labels }) => {\n const [draft, setDraft] = React.useState<string>(`${value}%`)\n const [menuOpen, setMenuOpen] = React.useState(false)\n\n React.useEffect(() => {\n setDraft(`${value}%`)\n }, [value])\n\n const commitDraft = () => {\n const parsed = parseInt(draft.replace(/[^0-9]/g, ''), 10)\n if (Number.isFinite(parsed) && parsed > 0) {\n // 限 10–400 範圍,對齊 ImageRenderer MIN_SCALE/MAX_SCALE\n const clamped = Math.min(400, Math.max(10, parsed))\n onChange(clamped)\n setDraft(`${clamped}%`)\n } else {\n setDraft(`${value}%`)\n }\n }\n\n return (\n // zoom group = toolbar 按鈕群組,`gap-2`(8px)對齊本 DS 按鈕 gap canonical。\n <div className=\"inline-flex items-center gap-2\">\n {/* 縮小 */}\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Minus}\n aria-label=\"縮小\"\n disabled={value <= 10}\n onClick={() => onChange(nextZoomOut(value))}\n />\n\n {/* % Input + chevron 內嵌為 endSlot(ItemInlineActionButton 作 DropdownMenuTrigger):\n — Input body 可自由打字(chevron 是 Input 內部 element,body 區域 click 不觸發 menu)\n — Chevron 是 inline action,同時是 DropdownMenuTrigger → menu 精確 anchor 在 chevron 下方\n — 靠 Radix asChild + ItemInlineActionButton:視覺是 Input + endAction,行為是 chevron-as-trigger\n — 完全對齊 user AR:「只有 inline action 能觸發選單,menu 對齊 inline action」 */}\n <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n {/* maxLength 限縮(2026-07-09 user 要求「找合理最大值避免無限輸入」):autoWidth 下輸入框寬度隨字數長,\n 無上限則可無限打字撐爆 toolbar。max 合法值「400%」= 4 字(對齊 ZOOM_PRESETS / ImageRenderer MAX_SCALE 400;\n commitDraft 另 clamp 10–400),留 1 字編輯緩衝 → maxLength={5}。 */}\n <Input\n size=\"sm\"\n autoWidth\n maxLength={5}\n aria-label={labels.zoomInput}\n value={draft}\n onChange={(e) => setDraft(e.target.value)}\n onBlur={commitDraft}\n onKeyDown={(e) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n commitDraft()\n ;(e.target as HTMLInputElement).blur()\n }\n }}\n className=\"text-center tabular-nums\"\n endSlot={\n <DropdownMenuTrigger asChild>\n <ItemInlineActionButton\n icon={ChevronDown}\n aria-label={labels.zoomMenu}\n size=\"sm\"\n overlayTrigger\n />\n </DropdownMenuTrigger>\n }\n />\n {/* data-theme=\"dark\":DropdownMenuContent 走 Portal 到 document body 外,\n 不繼承 FileViewer 外層 data-theme=\"dark\",需顯式打 dark 讓選單跟 chrome 一致。\n **加 bg-surface-raised 強制用 dark token**(純 data-theme attr 在 Portal 不夠,\n Tailwind 條件 class + CSS variable 都要一起帶) */}\n <DropdownMenuContent\n align=\"end\"\n sideOffset={OVERLAY_SIDE_OFFSET} // 2026-07-04:M17 — elevation.spec「primitive default 必 import 該 const」\n // minWidth 對齊 trigger(Input autoWidth),menu 寬度 fit-content 更貼近觸發點視覺中心\n className=\"min-w-[9rem] w-auto bg-surface-raised text-foreground border-divider\"\n data-theme=\"dark\"\n >\n {/* 內層 data-theme 再覆蓋一次 — 確保 DropdownMenuItem children 都 resolve dark token */}\n <div data-theme=\"dark\" className=\"contents\">\n {ZOOM_FIT_OPTIONS.map((opt) => (\n <DropdownMenuItem\n key={opt.value}\n onSelect={() => onFit(opt.value)}\n >\n {opt.label}\n </DropdownMenuItem>\n ))}\n <DropdownMenuSeparator />\n {ZOOM_PRESETS.map((p) => {\n const selected = p === value\n return (\n <DropdownMenuItem\n key={p}\n onSelect={() => onChange(p)}\n data-state={selected ? 'checked' : undefined}\n className={cn(\n 'tabular-nums',\n selected && 'bg-neutral-selected',\n )}\n >\n {p}%\n </DropdownMenuItem>\n )\n })}\n </div>\n </DropdownMenuContent>\n </DropdownMenu>\n\n {/* 放大 */}\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Plus}\n aria-label=\"放大\"\n disabled={value >= 400}\n onClick={() => onChange(nextZoomIn(value))}\n />\n </div>\n )\n}\nZoomInput.displayName = 'ZoomInput'\n\n// ─── Toolbar ──────────────────────────────────────────────────────────────────\n\ninterface ToolbarProps {\n file: FileInfo\n capabilities: FileRendererCapabilities\n zoom: number\n onZoomChange: (z: number) => void\n onFit: (fit: ZoomFit) => void\n infoOpen: boolean\n onInfoToggle: () => void\n onDownload?: () => void\n allowDownload: boolean\n onClose: () => void\n labels: Required<FileViewerLabels>\n}\n\nconst Toolbar: React.FC<ToolbarProps> = ({\n file,\n capabilities,\n zoom,\n onZoomChange,\n onFit,\n infoOpen,\n onInfoToggle,\n onDownload,\n allowDownload,\n onClose,\n labels,\n}) => {\n return (\n <ChromeHeader\n // Density:繼承 page(2026-06-15 canonical)。原 lockDensity=\"lg\" 只鎖在 header、沒鎖 body →\n // InfoPanel header(px-loose@lg=24)與其 body(px-loose@page=16)左緣不對齊(圖二 bug)。移除 lock\n // → header + body 全繼承 page density,左緣對齊;FileViewer 全 surface 同一密度(non-special)。\n className={cn(\n // Chrome layer — `bg-surface-raised` 對齊 token semantic「遮蓋型浮層必須不透明」。\n // FileViewer 整體是 overlay,chrome 屬其 raised surface(同 DropdownMenuContent line 244)。\n // 不用 bg-surface(dark = white α8 半透明,outer 透明時失去 backdrop 洗白)。\n // 不用 bg-canvas(那是「頁面最底層」semantic,chrome 不是 page)。\n // ChromeHeader 自帶 flex/items-center/gap-2/shrink-0/h-chrome-header-height/border-b/px-loose\n 'bg-surface-raised',\n )}\n >\n {/* 檔名(左,佔據可用寬度,ellipsis)—— file-type icon 代表檔名的意象(這是什麼檔),\n 對齊 CLAUDE.md「icon 代表 label 意象時與 label 同色」原則:icon 走 text-foreground\n 不走 text-fg-muted(後者是裝飾性 / 輔助 icon 的色階) */}\n <div className=\"flex items-center gap-2 min-w-0 flex-1\">\n <FileIcon size={16} className=\"text-foreground shrink-0\" aria-hidden />\n <span\n className=\"text-body-lg text-foreground truncate\"\n title={file.name}\n >\n {file.name}\n </span>\n </div>\n\n {/* 按鈕順序 canonical:zoom → info → download → close(影響力遞增)\n action-bar 三分區:zoom(data op)/ info+download(action group)/ close(dismiss)\n dismiss 前分隔線 = action-bar「dismiss 跟動作分群」canonical\n\n ── gap-2 canonical(2026-04-21 follow-up)──\n 按鈕間距 **8px**(gap-2),對齊 Dialog footer `gap-2` / CLAUDE 按鈕間距 SSOT。\n zoom group(ZoomInput)內部同樣是 gap-2(見 `ZoomInput` L197),\n 與這裡 action-group-to-action-group 的 gap-2 一致。 */}\n <div className=\"flex items-center gap-2 shrink-0\">\n {capabilities.zoom && (\n <>\n {/* Zoom group:-/%/+/▼ 屬同類「縮放」操作,群組並在右側加分隔線跟其他動作分群 */}\n <ZoomInput value={zoom} onChange={onZoomChange} onFit={onFit} labels={labels} />\n {/* zoom group → next action group divider(action-bar canonical;v11 升級成 Separator\n 元件,對齊 separator.spec.md「consumer 手動放置 toolbar 群組分隔線 = 用 Separator」)*/}\n <Separator orientation=\"vertical\" className=\"h-6 mx-1\" />\n </>\n )}\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Info}\n aria-label={infoOpen ? labels.infoToggleCollapse : labels.infoToggleExpand}\n pressed={infoOpen}\n onClick={onInfoToggle}\n />\n {allowDownload && (\n <Button\n variant=\"text\"\n size=\"sm\"\n iconOnly\n startIcon={Download}\n aria-label={labels.download}\n onClick={onDownload}\n />\n )}\n {/* action-bar canonical:dismiss 前加分隔線跟其他動作分群(info/download = action group,\n close = dismiss group;v11 升級成 Separator,對齊 separator.spec.md canonical)*/}\n <Separator orientation=\"vertical\" className=\"h-6 mx-1\" />\n {/* Close X 走 dismiss canonical(`<Button iconOnly dismiss />`)——對齊 CLAUDE.md\n `button.spec.md`「Dismiss 視覺類」+ `patterns/element-anatomy/item-anatomy.spec.md`\n 「Dismiss canonical」:chrome corner close X = Button dismiss,不是 Inline Action。 */}\n <Button\n iconOnly\n dismiss\n size=\"sm\"\n data-dismiss\n startIcon={XIcon}\n aria-label={labels.close}\n onClick={onClose}\n />\n </div>\n </ChromeHeader>\n )\n}\n\n// ─── InfoPanel ────────────────────────────────────────────────────────────────\n\ninterface InfoPanelProps {\n file: FileInfo\n readOnly: boolean\n onDescriptionChange?: (fileId: string, description: string) => void\n onClose: () => void\n labels: Required<FileViewerLabels>\n}\n\nfunction formatBytes(bytes: number | undefined): string | undefined {\n if (bytes == null) return undefined\n if (bytes < 1024) return `${bytes} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`\n}\n\n// React.memo(2026-07-06 D3):同 Filmstrip——wheel-zoom 每 tick 全 chrome re-render,InfoPanel\n// 開啟時(Field/Textarea/DescriptionList subtree)白付。onClose 走 shell handleInfoClose\n// useCallback;file(files[activeIndex] identity 穩定)/readOnly/onDescriptionChange/labels 本已穩定。\nconst InfoPanel = React.memo(function InfoPanel({\n file,\n readOnly,\n onDescriptionChange,\n onClose,\n labels,\n}: InfoPanelProps) {\n const [draft, setDraft] = React.useState(file.description ?? '')\n\n React.useEffect(() => {\n setDraft(file.description ?? '')\n }, [file.id, file.description])\n\n const commit = () => {\n if (readOnly) return\n if (draft !== (file.description ?? '')) {\n onDescriptionChange?.(file.id, draft)\n }\n }\n\n const sizeText = formatBytes(file.size)\n\n return (\n <aside\n className={cn(\n // Chrome — bg-surface-raised 同 Toolbar / Filmstrip(token semantic「遮蓋型浮層」)\n 'w-80 shrink-0 flex flex-col bg-surface-raised border-l border-divider',\n 'h-full',\n )}\n aria-label={labels.detailPanel}\n >\n {/* Panel header — 與 Toolbar 同 ChromeHeader,繼承 page density(2026-06-15:移除 lockDensity,\n 修 header(原 px-loose@lg=24)與 body(px-loose@page=16)左緣不對齊 = 圖二 bug;全 surface 同密度)。 */}\n <ChromeHeader className=\"justify-between\">\n <h3 className=\"text-body-lg font-medium text-foreground\">{labels.detailsHeading}</h3>\n {/* InfoPanel close 走 dismiss canonical `<Button iconOnly dismiss />`,對齊 button.spec.md\n 「Dismiss 視覺類」+ inline-action.spec.md「Dismiss canonical — X close only」。 */}\n <Button\n iconOnly\n dismiss\n size=\"sm\"\n data-dismiss\n startIcon={XIcon}\n aria-label={labels.detailPanelClose}\n onClick={onClose}\n />\n </ChromeHeader>\n\n {/* Panel body — header(shrink-0)上常駐 + body 走 ScrollArea(高度小時內容可捲動)。\n padding 對齊 layoutSpace v6 規則 4「bounded region → 容器底(無 action buttons)= loose」。\n gap 對齊 v6 規則 3「跨範疇 parallel = loose」(說明 vs 檔案資訊兩個獨立 functional sections,\n 屬「跨範疇 + 不相關」)— 從 gap-4 寫死改為 token-aware loose。 */}\n <ScrollArea className=\"flex-1 min-h-0\">\n <div className={cn(\n 'flex flex-col gap-[var(--layout-space-loose)]',\n 'px-[var(--layout-space-loose)]',\n 'pt-[var(--layout-space-tight)] pb-[var(--layout-space-loose)]',\n )}>\n {/* 說明 — 用 DS Field + FieldLabel + Textarea(2026-04-20 B12 決策:\n FileViewer 一律消費 DS Field 家族,不手刻 `<span>label` + raw control) */}\n <Field>\n <FieldLabel>說明</FieldLabel>\n <Textarea\n value={draft}\n onChange={(e) => setDraft(e.target.value)}\n onBlur={commit}\n readOnly={readOnly}\n placeholder={readOnly ? labels.descriptionPlaceholderReadOnly : labels.descriptionPlaceholderEdit}\n rows={5}\n />\n </Field>\n\n {/* 檔案資訊 — 用 DS DescriptionList horizontal + divided(Google Drive /\n Notion file info panel 模式):\n - section header 用 FieldLabel 同款 typography 保視覺一致\n - DescriptionList direction=\"horizontal\" divided 提供 row 下底線\n 對齊格線,key 長度不一也易讀\n - 不再手刻 dl/dt/dd — canonical 由 DS primitive own */}\n {/* heading → first-item gap = item → item gap(Gestalt proximity,見 description-list.spec.md) */}\n <div className=\"flex flex-col gap-[var(--layout-space-tight)]\">\n <span className=\"text-body font-normal text-foreground\">{labels.fileInfoHeading}</span>\n <DescriptionList direction=\"horizontal\" divided>\n <DescriptionItem label=\"檔名\">{file.name}</DescriptionItem>\n <DescriptionItem label=\"類型\">{file.mimeType || '-'}</DescriptionItem>\n {sizeText && (\n <DescriptionItem label=\"大小\">\n <span className=\"tabular-nums\">{sizeText}</span>\n </DescriptionItem>\n )}\n {file.metadata &&\n Object.entries(file.metadata).map(([k, v]) => (\n <DescriptionItem key={k} label={k}>{String(v)}</DescriptionItem>\n ))}\n </DescriptionList>\n </div>\n </div>\n </ScrollArea>\n </aside>\n )\n})\n\n// ─── Filmstrip ────────────────────────────────────────────────────────────────\n\ninterface FilmstripProps {\n files: FileInfo[]\n activeIndex: number\n onSelect: (index: number) => void\n labels: Pick<Required<FileViewerLabels>, 'filmstripLabel'>\n}\n\n// code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding\nconst THUMB_SIZE = 64 // px, 固定\n\n// React.memo(2026-07-06 D3):wheel-zoom 每 tick(trackpad 60Hz+)setZoom → shell 全 chrome\n// re-render,Filmstrip(N thumbs × cn + buildFadeMask)是最貴的無關 subtree。props 已全穩定\n// (files=prop / activeIndex=primitive / onSelect=setIndex useCallback / labels=useMemo)→ memo 真命中。\nconst Filmstrip = React.memo(function Filmstrip({ files, activeIndex, onSelect, labels }: FilmstripProps) {\n const { scrollRef, atStart, atEnd, canScroll } = useScrollEdges<HTMLDivElement>()\n const scrollByPage = useScrollByPage(scrollRef)\n const maskImage = buildFadeMask({ canScroll, atStart, atEnd, reserveArrowWidth: ARROW_BUTTON_WIDTH }) // 2026-07-04:消 SSOT 硬寫(spec 禁止重新定義 ARROW_BUTTON_WIDTH)\n\n // 切換當前檔案時,自動 scroll 讓 active thumb 可見\n React.useEffect(() => {\n const el = scrollRef.current\n if (!el) return\n const active = el.querySelector<HTMLButtonElement>(`[data-thumb-index=\"${activeIndex}\"]`)\n active?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })\n }, [activeIndex, scrollRef])\n\n return (\n <div\n className={cn(\n // Chrome — bg-surface-raised 同 Toolbar / InfoPanel(token semantic「遮蓋型浮層」)\n 'relative shrink-0 h-24 bg-surface-raised border-t border-divider',\n 'flex items-center',\n 'px-[var(--layout-space-loose)]',\n )}\n >\n {canScroll && !atStart && (\n <OverflowScrollArrow direction=\"left\" onClick={() => scrollByPage('left')} />\n )}\n <div\n ref={scrollRef}\n className={cn(\n 'flex items-center',\n // 刻意隱藏 native scrollbar + 用 fade-mask(horizontal-overflow pattern)\n 'scrollbar-none overflow-x-auto overflow-y-hidden h-full py-2',\n 'w-full',\n )}\n style={{\n maskImage,\n WebkitMaskImage: maskImage,\n }}\n >\n {/* 內層 wrapper:mx-auto 讓 thumbs 在少量時水平置中,多量溢出時 mx-auto = 0 自然轉 scroll。\n gap-[var(--layout-space-tight)] 走 DS density-aware token(不用 raw gap-1)——\n 世界級 idiom:Google Drive / Dropbox / Notion file preview 的 filmstrip 都是\n 少量置中 / 多量靠 start scroll。\n ARIA role=\"group\"(非 tablist):filmstrip 是「選圖導航」不是「切換 N 個 tabpanel」——\n lightbox 縮圖膠卷只有單一 viewport,不符 W3C tabs pattern(tab MUST 對應自己的 tabpanel + roving tabindex)。\n 對齊 APG grouped-carousel + PhotoSwipe / codeaccessible.com lightbox 共識:group + button + aria-current。 */}\n <div\n role=\"group\"\n aria-label={labels.filmstripLabel}\n className=\"flex items-center gap-[var(--layout-space-tight)] mx-auto shrink-0\"\n >\n {files.map((file, i) => {\n const active = i === activeIndex\n const isImage = canRenderImage(file)\n const ext = file.name.split('.').pop()?.toUpperCase() ?? '檔'\n return (\n <button\n key={file.id}\n type=\"button\"\n aria-current={active ? 'true' : undefined}\n aria-label={`${i + 1} / ${files.length}:${file.name}`}\n data-thumb-index={i}\n onClick={() => onSelect(i)}\n className={cn(\n 'shrink-0 rounded-md bg-muted overflow-hidden',\n 'outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'transition-shadow duration-150',\n active\n ? 'ring-2 ring-primary'\n : 'ring-1 ring-border hover:ring-border-hover',\n )}\n style={{ width: THUMB_SIZE, height: THUMB_SIZE }}\n >\n <AspectRatio ratio={1} className=\"w-full h-full\">\n {isImage ? (\n <img\n src={file.url}\n alt=\"\"\n aria-hidden\n className=\"w-full h-full object-cover\"\n draggable={false}\n />\n ) : (\n <div className=\"w-full h-full flex flex-col items-center justify-center gap-0.5\">\n <FileText size={20} className=\"text-fg-muted\" aria-hidden />\n <span className=\"text-footnote text-fg-muted font-medium\">{ext}</span>\n </div>\n )}\n </AspectRatio>\n </button>\n )\n })}\n </div>\n </div>\n {canScroll && !atEnd && (\n <OverflowScrollArrow direction=\"right\" onClick={() => scrollByPage('right')} />\n )}\n </div>\n )\n})\n\n// ─── FileViewer (shell) ───────────────────────────────────────────────────────\n\n/**\n * i18n-able labels for FileViewer chrome / controls.\n * All keys are optional — defaults are CJK (see `DEFAULT_LABELS`).\n * Consumer typically spreads partial override:\n * `<FileViewer labels={{ close: 'Close', download: 'Download' }} />`\n */\n// code-quality-allow: dead-export — public API surface — consumer-exposed for future use\nexport interface FileViewerLabels {\n /** Zoom input ARIA label */\n zoomInput?: string\n /** Zoom menu trigger ARIA label */\n zoomMenu?: string\n /** Info panel toggle button — shown when panel is OPEN */\n infoToggleCollapse?: string\n /** Info panel toggle button — shown when panel is CLOSED */\n infoToggleExpand?: string\n /** Download button ARIA label */\n download?: string\n /** Close viewer button ARIA label */\n close?: string\n /** InfoPanel outer aside ARIA label */\n detailPanel?: string\n /** InfoPanel heading text */\n detailsHeading?: string\n /** InfoPanel close button ARIA label */\n detailPanelClose?: string\n /** Description textarea placeholder (readOnly) */\n descriptionPlaceholderReadOnly?: string\n /** Description textarea placeholder (editable) */\n descriptionPlaceholderEdit?: string\n /** Detail section — file info section heading */\n fileInfoHeading?: string\n /** Filmstrip group ARIA label(role=\"group\" + thumb button + aria-current,非 tablist)*/\n filmstripLabel?: string\n /** Previous-file nav button ARIA label */\n previousFile?: string\n /** Next-file nav button ARIA label */\n nextFile?: string\n}\n\n// i18n-allow: DS defaults;consumer override via `labels` prop\nconst DEFAULT_LABELS: Required<FileViewerLabels> = {\n zoomInput: '縮放比例',\n zoomMenu: '開啟縮放選單',\n infoToggleCollapse: '收合詳細資訊面板',\n infoToggleExpand: '展開詳細資訊面板',\n download: '下載檔案',\n close: '關閉檢視器',\n detailPanel: '檔案詳細資訊',\n detailsHeading: '詳細資訊',\n detailPanelClose: '關閉詳細資訊',\n descriptionPlaceholderReadOnly: '尚無說明',\n descriptionPlaceholderEdit: '為這個檔案加上說明…',\n fileInfoHeading: '檔案資訊',\n filmstripLabel: '檔案佇列',\n previousFile: '上一個檔案',\n nextFile: '下一個檔案',\n}\n\nexport interface FileViewerProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,\n 'onOpenChange'\n > {\n files: FileInfo[]\n initialIndex?: number\n /** Controlled open state。與 `defaultOpen` 二擇一。 */\n open?: boolean\n /** Uncontrolled open 預設(2026-04-25 加,對齊 Dialog/Sheet/Popover dual-mode canonical)。 */\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n /** 當前索引(controlled);consumer 想自己控制 active file 時傳。不傳則 shell 管理。 */\n index?: number\n onIndexChange?: (index: number) => void\n /** 當前檔案 description 變化。consumer 負責持久化。readOnly 為 true 時不觸發。 */\n onDescriptionChange?: (fileId: string, description: string) => void\n /** true → InfoPanel 的 description textarea 為 readOnly。預設 false。 */\n readOnly?: boolean\n /** 顯示底部 filmstrip。預設 false;files.length < 2 時自動隱藏。 */\n showFilmstrip?: boolean\n /** 是否提供 download 按鈕。預設 true。 */\n allowDownload?: boolean\n /** 自訂 download 行為;未傳則用 anchor download attribute。 */\n onDownload?: (file: FileInfo) => void\n /** i18n labels override. Partial — merged with DS defaults. */\n labels?: FileViewerLabels\n}\n\nconst FileViewer = React.forwardRef<HTMLDivElement, FileViewerProps>(function FileViewer({\n files,\n initialIndex = 0,\n open: openProp,\n defaultOpen,\n onOpenChange,\n index: indexProp,\n onIndexChange,\n onDescriptionChange,\n readOnly = false,\n showFilmstrip = false,\n allowDownload = true,\n onDownload,\n labels: labelsOverride,\n ...props\n}, ref) {\n const labels = React.useMemo(\n () => ({ ...DEFAULT_LABELS, ...labelsOverride }) satisfies Required<FileViewerLabels>,\n [labelsOverride],\n )\n // Open:受控/非受控雙模式鏡像(消費 DS useControllable;select-menu.tsx 2026-06-11 同修先例)。\n // 原本 keyboard shortcuts effect 與 index-reset effect 直接讀 `open` prop ——\n // uncontrolled(defaultOpen)場景 open === undefined,兩 effect 永遠不啟動(快捷鍵全失效\n // + 重開不重置 index),Toolbar X / backdrop click 的 onOpenChange?.(false) 也是 no-op。\n // 鏡像後 effect / close 都走 mirror:controlled 時純 passthrough,\n // uncontrolled 時 internal state 為準 + onOpenChange 僅通知。\n const [open, setOpen] = useControllable<boolean>({\n value: openProp,\n defaultValue: defaultOpen ?? false,\n onChange: onOpenChange,\n })\n\n // Index:uncontrolled fallback\n const [internalIndex, setInternalIndex] = React.useState(initialIndex)\n const activeIndex = indexProp ?? internalIndex\n\n const setIndex = React.useCallback(\n (next: number) => {\n const clamped = Math.max(0, Math.min(files.length - 1, next))\n if (indexProp === undefined) setInternalIndex(clamped)\n onIndexChange?.(clamped)\n },\n [files.length, indexProp, onIndexChange],\n )\n\n // 開啟時若 uncontrolled,重置為 initialIndex\n React.useEffect(() => {\n if (open && indexProp === undefined) {\n setInternalIndex(Math.max(0, Math.min(files.length - 1, initialIndex)))\n }\n }, [open, initialIndex, files.length, indexProp])\n\n // Info panel open state(shell own)\n const [infoOpen, setInfoOpen] = React.useState(false)\n // 2026-07-06 D3 perf:抽 useCallback 穩定 identity——InfoPanel 上了 React.memo(見上),\n // inline arrow 每 render 新 identity 會打穿 memo shallow-compare。\n const handleInfoToggle = React.useCallback(() => setInfoOpen((o) => !o), [])\n const handleInfoClose = React.useCallback(() => setInfoOpen(false), [])\n\n // Zoom state(shell own,renderer 消費 + 回報)\n const [zoom, setZoom] = React.useState(100)\n // 切換檔案時不再 setZoom(100)— 把「下一張該怎麼初始化 zoom」的決定權交給 renderer:\n // image-renderer 自己 watch file.url change → reset loaded → onLoad → 重 fit-page。\n // 原本 setZoom(100) 在 cache 命中(onLoad 沒 fire)時會卡 100% 不 fit(user 抓的 bug)。\n\n // Fit request(shell → renderer 指令;nonce 遞增讓重複同 fit 也觸發 renderer)\n const [fitRequest, setFitRequest] = React.useState<{ fit: ZoomFit; nonce: number } | null>(null)\n\n // Renderer capabilities(mount 時 renderer emit)\n const [capabilities, setCapabilities] = React.useState<FileRendererCapabilities>({\n zoom: false,\n })\n\n const file = files[activeIndex]\n const Renderer = file ? resolveRenderer(file) : null\n\n // Fit-to-* 下指令給 renderer,renderer 算 container/image 比例後透過 onZoomChange 回報\n const handleFit = React.useCallback((fit: ZoomFit) => {\n setFitRequest((prev) => ({ fit, nonce: (prev?.nonce ?? 0) + 1 }))\n }, [])\n\n // Download handler\n const handleDownload = React.useCallback(() => {\n if (!file) return\n if (onDownload) {\n onDownload(file)\n return\n }\n // 預設:anchor download(同源檔案有效;跨域由 consumer 提供 onDownload)\n const a = document.createElement('a')\n a.href = file.url\n a.download = file.name\n a.target = '_blank'\n a.rel = 'noopener'\n document.body.appendChild(a)\n a.click()\n document.body.removeChild(a)\n }, [file, onDownload])\n\n // Keyboard shortcuts(focus 在 input / textarea 時不觸發)\n React.useEffect(() => {\n if (!open) return\n const handler = (e: KeyboardEvent) => {\n const target = e.target as HTMLElement | null\n const tag = target?.tagName\n if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return\n\n if (e.key === 'ArrowLeft' && files.length > 1) {\n e.preventDefault()\n setIndex(activeIndex - 1)\n } else if (e.key === 'ArrowRight' && files.length > 1) {\n e.preventDefault()\n setIndex(activeIndex + 1)\n } else if (e.key === '+' || e.key === '=') {\n if (capabilities.zoom) {\n e.preventDefault()\n setZoom((z) => nextZoomIn(z))\n }\n } else if (e.key === '-') {\n if (capabilities.zoom) {\n e.preventDefault()\n setZoom((z) => nextZoomOut(z))\n }\n } else if (e.key === '0') {\n if (capabilities.zoom) {\n e.preventDefault()\n setZoom(100)\n }\n } else if (e.key === 'f' || e.key === 'F') {\n if (capabilities.zoom) {\n e.preventDefault()\n handleFit('fit-page')\n }\n } else if (e.key === 'i' || e.key === 'I') {\n e.preventDefault()\n setInfoOpen((o) => !o)\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n }, [open, activeIndex, files.length, setIndex, capabilities.zoom, handleFit])\n\n // Arrows idle auto-hide(世界級 lightbox canonical:Google Photos / Dropbox / PhotoSwipe)\n // 滑鼠移入 viewport → 顯示箭頭;持續 2.5 秒無 mouse move → 自動淡出(對齊世界級行為)\n const [armVisible, setArmVisible] = React.useState(false)\n const idleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const handleViewportMouseMove = React.useCallback(() => {\n setArmVisible(true)\n if (idleTimerRef.current) clearTimeout(idleTimerRef.current)\n idleTimerRef.current = setTimeout(() => setArmVisible(false), 2500)\n }, [])\n const handleViewportMouseLeave = React.useCallback(() => {\n setArmVisible(false)\n if (idleTimerRef.current) clearTimeout(idleTimerRef.current)\n }, [])\n React.useEffect(() => () => {\n if (idleTimerRef.current) clearTimeout(idleTimerRef.current)\n }, [])\n\n if (!file || !Renderer) {\n // files 為空或 index 超界 — 不渲染\n return null\n }\n\n const showFilmstripResolved = showFilmstrip && files.length > 1\n const showArrows = files.length > 1\n\n // Root 永遠餵 mirror(Radix 視角 controlled);uncontrolled 的 defaultOpen 由\n // useControllable internal state 承載,Radix Esc / dismiss 經 setOpen 同步回 mirror。\n return (\n <DialogPrimitive.Root open={open} onOpenChange={setOpen}>\n <DialogPrimitive.Portal>\n {/* Overlay — FileViewer 固定深色氛圍,與 Dialog 共用 bg-overlay。\n **data-theme=\"dark\"**(2026-04-30):Overlay 在 Portal 內、是 Content 的 sibling,\n 不繼承 Content 內層的 dark 主題 → `--overlay` 默認 resolve 成 light theme α45 黑。\n FileViewer 永遠 dark(line 899 outer),mask 也須 dark token = α65 黑(更深)\n 才語意一致。同 DropdownMenuContent Portal 處理(line 245)。 */}\n <DialogPrimitive.Overlay\n data-theme=\"dark\"\n className={cn(\n 'fixed inset-0 z-50 bg-overlay',\n surfaceMotion,\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n )}\n />\n <DialogPrimitive.Content\n ref={ref}\n className={cn(\n // Edge-to-edge fullscreen,無 inset / 無 radius(與一般 Dialog 差別的所在)\n 'fixed inset-0 z-50 outline-none',\n surfaceMotion,\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',\n )}\n // 避免 Radix 自動把焦點送進 Content 的第一個 tabbable —— 我們要留給 viewport\n onOpenAutoFocus={(e) => e.preventDefault()}\n {...props}\n >\n {/* 鎖 dark subtree。Density 繼承 page(不另設 data-density)。\n header 高度透過 `--chrome-header-height` 自動 density-aware(md=48 / lg=56)。\n ── Q1 mask 透明度(2026-04-30)──\n outer **不**設 bg → Overlay(`bg-overlay` α45/α65)透出 image 周圍區域,\n 對齊 Notion / Dropbox / Slack lightbox idiom 跟 Dialog mask 同 token 一致。\n chrome(Toolbar / Filmstrip / InfoPanel)各自 `bg-surface-raised` opaque dark\n (對齊 Apple Photos / Drive lightbox 派 — chrome opaque vs mask 半透明,\n 清楚區分 backdrop click 區 vs 互動區)。\n **不**用 bg-surface — dark mode `--surface = white α8` 半透明,outer 透明時\n 無 dark backdrop 撐 → 視覺洗白。 */}\n <div\n data-theme=\"dark\"\n className=\"w-full h-full flex flex-col text-foreground\"\n >\n {/* Accessible title — 視覺隱藏但 screen reader 讀得到 */}\n <DialogPrimitive.Title className=\"sr-only\">\n 檔案檢視器:{file.name}\n </DialogPrimitive.Title>\n\n <Toolbar\n file={file}\n capabilities={capabilities}\n zoom={zoom}\n onZoomChange={setZoom}\n onFit={handleFit}\n infoOpen={infoOpen}\n onInfoToggle={handleInfoToggle}\n onDownload={handleDownload}\n allowDownload={allowDownload}\n onClose={() => setOpen(false)}\n labels={labels}\n />\n\n {/* 主區:Viewport + 可選 InfoPanel(右側)\n Arrows visibility = armVisible(state)控制:mouse move 顯示 / 2.5s idle 隱藏 / mouse leave 立即隱藏\n 對齊 Google Photos / Dropbox lightbox / PhotoSwipe world-class canonical */}\n <div className=\"flex-1 min-h-0 flex\">\n <div\n className=\"relative flex-1 min-w-0\"\n onMouseMove={handleViewportMouseMove}\n onMouseLeave={handleViewportMouseLeave}\n // Backdrop click-to-close(對齊 Google Drive / Dropbox lightbox / Apple Photos canonical):\n // 點擊 image 周圍的暗色 backdrop 區關閉,跟 modal mask 同 idiom。\n //\n // 為何 geometric check 而非 closest('img')?\n // react-zoom-pan-pinch 的 TransformComponent 是 wrapper div 蓋在 image 之上(absorb\n // pan/zoom events),click target 是該 wrapper div 不是 <img>。closest('img') 檢查\n // ancestor 永遠 false。改 geometric check:看 click 座標是否落在 <img> 視覺 rect 內。\n onClick={(e) => {\n const t = e.target as HTMLElement\n // 互動元素(side arrows / chrome buttons 透過冒泡)→ 不關\n if (t.closest('button, [role=\"button\"]')) return\n // 點到 image 視覺範圍 → 不關(image 本體 click ≠ close)\n const img = e.currentTarget.querySelector('img')\n if (img) {\n const r = img.getBoundingClientRect()\n if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return\n }\n // 否則 = 點到 backdrop(outer 不設 bg,半透明 bg-overlay 透出 — 見 Q1 註解)→ close\n setOpen(false)\n }}\n >\n {showArrows && activeIndex > 0 && (\n <div\n className={cn(\n 'absolute left-[var(--layout-space-loose)] top-1/2 -translate-y-1/2 z-10',\n 'transition-opacity duration-150',\n // armVisible state 控制,或 focus-within 時 a11y 強制顯示\n armVisible ? 'opacity-100' : 'opacity-0 pointer-events-none',\n 'focus-within:opacity-100 focus-within:pointer-events-auto',\n )}\n >\n <Button\n variant=\"text\"\n size=\"md\"\n iconOnly\n startIcon={ChevronLeft}\n aria-label={labels.previousFile}\n onClick={() => setIndex(activeIndex - 1)}\n />\n </div>\n )}\n <div className=\"w-full h-full\">\n {/* Suspense 兜 React.lazy renderer(ImageRenderer)chunk 載入。fallback=null\n = 既有 loading 行為——spec「Loading」段:shell 不提供全頁 loading state,\n loading 由 renderer 自理;chunk 載入期間的空 viewport 與 <img> 載入期間視覺一致。 */}\n <React.Suspense fallback={null}>\n <Renderer.component\n file={file}\n zoom={zoom}\n onZoomChange={setZoom}\n fitRequest={fitRequest}\n onCapabilitiesChange={setCapabilities}\n />\n </React.Suspense>\n </div>\n {showArrows && activeIndex < files.length - 1 && (\n <div\n className={cn(\n 'absolute right-[var(--layout-space-loose)] top-1/2 -translate-y-1/2 z-10',\n 'transition-opacity duration-150',\n armVisible ? 'opacity-100' : 'opacity-0 pointer-events-none',\n 'focus-within:opacity-100 focus-within:pointer-events-auto',\n )}\n >\n <Button\n variant=\"text\"\n size=\"md\"\n iconOnly\n startIcon={ChevronRight}\n aria-label={labels.nextFile}\n onClick={() => setIndex(activeIndex + 1)}\n />\n </div>\n )}\n </div>\n {infoOpen && (\n <InfoPanel\n file={file}\n readOnly={readOnly}\n onDescriptionChange={onDescriptionChange}\n onClose={handleInfoClose}\n labels={labels}\n />\n )}\n </div>\n\n {showFilmstripResolved && (\n <Filmstrip\n files={files}\n activeIndex={activeIndex}\n onSelect={setIndex}\n labels={labels}\n />\n )}\n </div>\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n </DialogPrimitive.Root>\n )\n})\nFileViewer.displayName = 'FileViewer'\n\n// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)\n// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs\nexport const fileViewerMeta = {\n component: 'FileViewer',\n family: null, // non-family composite / overlay / layout\n variants: {\n\n },\n sizes: {\n\n },\n // 'selected' = 縮圖列當前頁持續選中(bg-neutral-selected);'active' 移除 — 全檔無按壓視覺\n // (工具列按壓屬內嵌 Button meta;2026-07-07 詞彙統一 DS-wide 交叉掃補修)。\n states: ['default', 'hover', 'selected', 'focus-visible', 'disabled'],\n tokens: {\n bg: ['bg-muted', 'bg-neutral-selected', 'bg-overlay', 'bg-surface-raised'],\n fg: ['text-fg-muted', 'text-foreground'],\n ring: ['ring-primary', 'ring-ring'],\n },\n} as const\n\nexport { FileViewer }\nexport type { FileInfo, FileRenderer, FileRendererCapabilities, FileRendererProps } from './file-viewer-types'\n"],"names":["FileIcon","XIcon","InfoPanel","Filmstrip","FileViewer"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-renderer.d.ts","sourceRoot":"","sources":["../../../src/components/FileViewer/image-renderer.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"image-renderer.d.ts","sourceRoot":"","sources":["../../../src/components/FileViewer/image-renderer.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAO9B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AA6B5D,eAAO,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CA4OrD,CAAA"}
|
|
@@ -64,12 +64,33 @@ const ImageRenderer = ({
|
|
|
64
64
|
const container = containerRef.current;
|
|
65
65
|
if (!container) return;
|
|
66
66
|
let rafId = 0;
|
|
67
|
+
let prevW = container.clientWidth;
|
|
68
|
+
let prevH = container.clientHeight;
|
|
67
69
|
const obs = new ResizeObserver(() => {
|
|
68
70
|
if (rafId) cancelAnimationFrame(rafId);
|
|
69
71
|
rafId = requestAnimationFrame(() => {
|
|
70
72
|
rafId = 0;
|
|
71
73
|
const mode = lastFitModeRef.current;
|
|
72
|
-
|
|
74
|
+
const newW = container.clientWidth;
|
|
75
|
+
const newH = container.clientHeight;
|
|
76
|
+
if (mode === "manual") {
|
|
77
|
+
const api = apiRef.current;
|
|
78
|
+
if (api && (newW !== prevW || newH !== prevH)) {
|
|
79
|
+
const { positionX, positionY, scale: scale2 } = api.state;
|
|
80
|
+
const cx = (prevW / 2 - positionX) / scale2;
|
|
81
|
+
const cy = (prevH / 2 - positionY) / scale2;
|
|
82
|
+
programmaticZoomRef.current = true;
|
|
83
|
+
api.setTransform(newW / 2 - cx * scale2, newH / 2 - cy * scale2, scale2, 0);
|
|
84
|
+
requestAnimationFrame(() => {
|
|
85
|
+
programmaticZoomRef.current = false;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
prevW = newW;
|
|
89
|
+
prevH = newH;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
prevW = newW;
|
|
93
|
+
prevH = newH;
|
|
73
94
|
const scale = computeFitScale(mode);
|
|
74
95
|
if (scale == null) return;
|
|
75
96
|
onZoomChange(clampToPct(scale));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-renderer.js","sources":["../../../src/components/FileViewer/image-renderer.tsx"],"sourcesContent":["// @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.\nimport * as React from 'react'\nimport {\n TransformWrapper,\n TransformComponent,\n type ReactZoomPanPinchRef,\n} from 'react-zoom-pan-pinch'\nimport type { FileRendererProps } from './file-viewer-types'\n\n/**\n * ImageRenderer — FileViewer 的圖片 renderer。\n *\n * ── 世界級 zoom semantic canonical(2026-04-21 重寫)──\n * Figma / Preview.app / Adobe Acrobat / Google Drive 共通:\n * - `100%` = image natural pixel size(**非** CSS contain-scaled)\n * - 開圖預設 fit-to-page(image 自動 fit,zoom input 顯示 fit % 如 40%)\n * - `fit-to-width` = image width 填滿 container width(portrait 會 overflow 垂直)\n * - `fit-to-page` = image 完整可見(contain semantic)\n * - `+/-` preset 改 zoom 對應 natural 倍率,精準\n *\n * ── 實作細節 ──\n * image 不走 CSS `object-contain`(那會 pre-scale,導致 transform.scale 解讀錯誤);\n * 改走 **natural size + transform scale 管實際顯示**。onLoad 時算 fit-page scale\n * 再 `onZoomChange(fitPct)` 將 UI zoom 同步到真實倍率。\n *\n * ── 為什麼消費 react-zoom-pan-pinch ──\n * Zoom + pan 是行為 primitive;自寫 pinch / wheel 踩大量 edge case\n * (trackpad vs mouse / momentum / bounds),library 是 canonical 解法\n * (世界級 Figma Community / Miro embed / PhotoSwipe 同類流派)。\n */\n\nconst MIN_SCALE = 0.1 // 10%\nconst MAX_SCALE = 4.0 // 400%\n\ntype FitMode = 'fit-width' | 'fit-page'\n\nexport const ImageRenderer: React.FC<FileRendererProps> = ({\n file,\n zoom,\n onZoomChange,\n fitRequest,\n onCapabilitiesChange,\n}) => {\n const apiRef = React.useRef<ReactZoomPanPinchRef | null>(null)\n const imgRef = React.useRef<HTMLImageElement | null>(null)\n const containerRef = React.useRef<HTMLDivElement | null>(null)\n const [loaded, setLoaded] = React.useState(false)\n // 2026-05-16 Round 5 codex audit fix:capture image-load rAF ID + cancel on unmount\n // (原 uncancelled rAF 可能 unmount 後 fire onZoomChange → setState 後 component 已 gone)\n const handleImageLoadRafIdRef = React.useRef<number>(0)\n React.useEffect(() => () => { if (handleImageLoadRafIdRef.current) cancelAnimationFrame(handleImageLoadRafIdRef.current) }, [])\n // Q2 RWD:track「user 最後一次的 zoom 意圖」— fit-page / fit-width 自動 reflow,manual 不動。\n // 對齊 Apple Photos / Drive canonical:resize 時若 user 在 fit mode → recompute,manual zoom → 維持。\n const lastFitModeRef = React.useRef<FitMode | 'manual'>('fit-page')\n // 區分「user wheel/pinch」vs「programmatic centerView」— lib onTransform 兩者都觸發,\n // 用 flag 防 programmatic 的 onTransform 誤標 mode = manual。\n const programmaticZoomRef = React.useRef(false)\n\n // 宣告 capability — shell 用此決定 toolbar 內容。\n React.useEffect(() => {\n onCapabilitiesChange({ zoom: true })\n }, [onCapabilitiesChange])\n\n // file.url 切換 → reset state,等 onLoad 重 fit。\n // 原本 bug:cache 命中時 onLoad 不 fire → handleImageLoad 不跑 → zoom 卡上一張的值\n // (或 shell 設的 100%)→ user 看到「同一張圖每次切過來尺寸不一致」。\n React.useEffect(() => {\n setLoaded(false)\n lastFitModeRef.current = 'fit-page'\n // cache 命中(<img complete>)→ onLoad 可能不 fire,直接觸發 handleImageLoad 邏輯\n const img = imgRef.current\n if (img && img.complete && img.naturalWidth > 0) {\n // 等下一個 microtask,確保 ref / state 都到位\n Promise.resolve().then(() => handleImageLoad())\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [file.url])\n\n // 算 fit scale(container 寬高 / image natural 寬高)\n const computeFitScale = React.useCallback((fit: FitMode): number | null => {\n const img = imgRef.current\n const container = containerRef.current\n if (!img || !container) return null\n if (!img.naturalWidth || !img.naturalHeight) return null\n const cw = container.clientWidth\n const ch = container.clientHeight\n if (cw <= 0 || ch <= 0) return null\n const widthRatio = cw / img.naturalWidth\n const heightRatio = ch / img.naturalHeight\n // fit-width = 寬填滿;fit-page = 完整可見(取較小 scale)\n return fit === 'fit-width' ? widthRatio : Math.min(widthRatio, heightRatio)\n }, [])\n\n const clampToPct = React.useCallback((scale: number): number => {\n const clamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))\n // Floor 而非 round:fit-to-page / fit-to-width 時 scale 是 float(e.g. 0.8356)。\n // round(0.8356 * 100) = 84 → 實際 scale 0.84 → image 比 canvas 大 4px,垂直溢出破壞\n // 對稱置中(user 抓:「上下邊距不對稱」)。Floor → 0.83 → image 比 canvas 小,永遠\n // 完整可見 + 視覺 symmetric padding。代價是最多 ~1% 的空間餘量,視覺幾乎看不出。\n return Math.floor(clamped * 100)\n }, [])\n\n // Image onLoad → 自動 fit-to-page(世界級開圖預設)\n const handleImageLoad = React.useCallback(() => {\n setLoaded(true)\n const scale = computeFitScale('fit-page')\n if (scale == null) return\n const pct = clampToPct(scale)\n lastFitModeRef.current = 'fit-page'\n // 等 transform 就緒再更新(避免 initialScale=1 → fit 過程跳兩段)\n if (handleImageLoadRafIdRef.current) cancelAnimationFrame(handleImageLoadRafIdRef.current)\n handleImageLoadRafIdRef.current = requestAnimationFrame(() => {\n handleImageLoadRafIdRef.current = 0\n onZoomChange(pct)\n })\n }, [computeFitScale, clampToPct, onZoomChange])\n\n // Q2 RWD:container resize 時若在 fit mode 重算 — 對齊 Apple Photos / Drive canonical\n // rAF debounce:drag window edge 期間 ResizeObserver 連續 fire 數十次,\n // 合併到下一 frame 只觸發一次 → 避免 race / 過多 centerView animation 互相打斷。\n React.useEffect(() => {\n if (!loaded) return\n const container = containerRef.current\n if (!container) return\n let rafId = 0\n const obs = new ResizeObserver(() => {\n if (rafId) cancelAnimationFrame(rafId)\n rafId = requestAnimationFrame(() => {\n rafId = 0\n const mode = lastFitModeRef.current\n if (mode === 'manual') return\n const scale = computeFitScale(mode)\n if (scale == null) return\n onZoomChange(clampToPct(scale))\n })\n })\n obs.observe(container)\n return () => {\n obs.disconnect()\n if (rafId) cancelAnimationFrame(rafId)\n }\n }, [loaded, computeFitScale, clampToPct, onZoomChange])\n\n // Q3 雙擊 toggle fit ↔ 100%(對齊 Apple Photos / Preview.app / Imgur / PhotoSwipe canonical)\n const handleDoubleClick = React.useCallback(() => {\n if (!loaded) return\n const fitScale = computeFitScale('fit-page')\n if (fitScale == null) return\n const fitPct = clampToPct(fitScale)\n // 在 fit-page 附近(±5pt)→ 跳 100% natural;否則 → 回 fit-page\n const atFit = Math.abs(zoom - fitPct) < 5\n const targetPct = atFit ? 100 : fitPct\n lastFitModeRef.current = atFit ? 'manual' : 'fit-page' // 跳 100% = manual,回 fit = fit mode\n onZoomChange(targetPct)\n }, [loaded, zoom, computeFitScale, clampToPct, onZoomChange])\n\n // 外部 zoom 變動(preset / ± / 打字 / fit request)→ centerView 重定位\n // library canonical `centerView` 同時處理 scale + 置中 + animation + bounds。\n React.useEffect(() => {\n const api = apiRef.current\n if (!api || !loaded) return\n const currentScale = api.state.scale\n const targetScale = zoom / 100\n if (Math.abs(currentScale - targetScale) < 0.005) return\n // 標記 programmatic — onTransform 期間不要被誤標 manual mode\n programmaticZoomRef.current = true\n api.centerView(targetScale, 200)\n // 動畫 ~200ms + buffer 後解 flag\n const t = setTimeout(() => { programmaticZoomRef.current = false }, 280)\n return () => clearTimeout(t)\n }, [zoom, loaded])\n\n // Fit request(toolbar 菜單點 fit-width / fit-page)→ 算 scale emit 回 shell\n React.useEffect(() => {\n if (!fitRequest || !loaded) return\n const scale = computeFitScale(fitRequest.fit)\n if (scale == null) return\n lastFitModeRef.current = fitRequest.fit // 記 fit mode 給 ResizeObserver 用\n onZoomChange(clampToPct(scale))\n }, [fitRequest, loaded, computeFitScale, clampToPct, onZoomChange])\n\n // 內部 wheel / pinch zoom → 同步回 shell + 標記為 manual mode(打破 fit auto-reflow)\n // programmatic centerView 期間 lib 也會 fire onTransform → 用 flag 跳過,避免誤標 manual。\n const handleTransformed = React.useCallback(\n (_ref: ReactZoomPanPinchRef, state: { scale: number }) => {\n if (programmaticZoomRef.current) return // programmatic update,不標 manual\n const nextZoom = Math.round(state.scale * 100)\n if (nextZoom !== zoom) {\n lastFitModeRef.current = 'manual'\n onZoomChange(nextZoom)\n }\n },\n [zoom, onZoomChange],\n )\n\n return (\n <div ref={containerRef} className=\"w-full h-full overflow-hidden\" onDoubleClick={handleDoubleClick}>\n <TransformWrapper\n // any-allow: react-zoom-pan-pinch TransformWrapper ref type not exported; API surface stable per lib docs\n ref={apiRef as any}\n initialScale={1}\n minScale={MIN_SCALE}\n maxScale={MAX_SCALE}\n centerOnInit\n centerZoomedOut\n // Teams 對標(2026-04-23):image viewer 走 chat-app lightbox 慣例 —\n // drag 時 image 保持在 canvas bounds 內(zoom-fit 時 drag 無意義,zoom-in 時 drag pan 有限制)。\n // `limitToBounds=true` 跟 Microsoft Teams / Slack / iOS Photos 等 chat-lightbox 互動一致,\n // 避免 Figma-canvas 式「可 drag 到任意位置」的無界體驗混淆 viewer 語境。\n limitToBounds={true}\n // Wheel zoom canonical:\n // - `step: 0.03` = 每 tick ~3% scale,對齊 Figma / Preview.app 細緻度\n // (原 0.1 = 10% 太粗,接近 Google Slides 離散慣例)\n // - multiplicative 等距:library 內部 scale factor 乘算,log 視覺等距\n // 註:原本用 `smoothStep: 0.005` 但當前 lib type 不含該 key;若需 trackpad 細緻,\n // 升級 react-zoom-pan-pinch 到有此 prop 的版本或切到 `smoothScroll` API\n wheel={{ step: 0.03 }}\n // Q3 雙擊改自定 handler:lib `mode: 'reset'` 永遠 reset 到 initialScale=1 → 100%,\n // 失去 fit ↔ 100% toggle UX(Apple Photos / Drive canonical)。disabled lib 預設 + 自定 onDoubleClick。\n doubleClick={{ disabled: true }}\n onTransform={handleTransformed}\n >\n {/* 2026-04-23 debug fix:contentClass 不設 `!w-full !h-full`。\n 設 `!w-full !h-full` 會讓 `.react-transform-component` 強制 1280×752 容器尺寸,\n 但 image 是 natural 1440×900(自然溢出 container)。Library `centerView(scale)`\n 基於 component 尺寸計算 translate → 計算偏 61px(視 image 被 WRAPPER 框住而非\n 自然 size)。\n 移除 content fixed size 後:component 自然 size = image natural → library 以\n image 實際尺寸計算置中,translate 正確(42.4, 2.5)得到 symmetric padding。\n wrapper 保留 `!w-full !h-full` 作 interaction capture bounds。 */}\n <TransformComponent wrapperClass=\"!w-full !h-full\">\n <img\n ref={imgRef}\n src={file.url}\n alt={file.name}\n onLoad={handleImageLoad}\n draggable={false}\n // natural size(**不走 object-contain**)— transform scale 管實際顯示大小\n className=\"max-w-none max-h-none select-none\"\n style={{ pointerEvents: 'none' }}\n />\n </TransformComponent>\n </TransformWrapper>\n </div>\n )\n}\nImageRenderer.displayName = 'ImageRenderer'\n\n// canRenderImage 已拆至 ./can-render-image(2026-07-06 D3 bundle fix)——\n// shell 需同步呼叫它,而本檔頂層 import react-zoom-pan-pinch;留在本檔會把整個 lib\n// 拉進 shell 的靜態 import graph,React.lazy 切分就失效。\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"image-renderer.js","sources":["../../../src/components/FileViewer/image-renderer.tsx"],"sourcesContent":["// @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.\nimport * as React from 'react'\nimport {\n TransformWrapper,\n TransformComponent,\n type ReactZoomPanPinchRef,\n type ReactZoomPanPinchContentRef,\n} from 'react-zoom-pan-pinch'\nimport type { FileRendererProps } from './file-viewer-types'\n\n/**\n * ImageRenderer — FileViewer 的圖片 renderer。\n *\n * ── 世界級 zoom semantic canonical(2026-04-21 重寫)──\n * Figma / Preview.app / Adobe Acrobat / Google Drive 共通:\n * - `100%` = image natural pixel size(**非** CSS contain-scaled)\n * - 開圖預設 fit-to-page(image 自動 fit,zoom input 顯示 fit % 如 40%)\n * - `fit-to-width` = image width 填滿 container width(portrait 會 overflow 垂直)\n * - `fit-to-page` = image 完整可見(contain semantic)\n * - `+/-` preset 改 zoom 對應 natural 倍率,精準\n *\n * ── 實作細節 ──\n * image 不走 CSS `object-contain`(那會 pre-scale,導致 transform.scale 解讀錯誤);\n * 改走 **natural size + transform scale 管實際顯示**。onLoad 時算 fit-page scale\n * 再 `onZoomChange(fitPct)` 將 UI zoom 同步到真實倍率。\n *\n * ── 為什麼消費 react-zoom-pan-pinch ──\n * Zoom + pan 是行為 primitive;自寫 pinch / wheel 踩大量 edge case\n * (trackpad vs mouse / momentum / bounds),library 是 canonical 解法\n * (世界級 Figma Community / Miro embed / PhotoSwipe 同類流派)。\n */\n\nconst MIN_SCALE = 0.1 // 10%\nconst MAX_SCALE = 4.0 // 400%\n\ntype FitMode = 'fit-width' | 'fit-page'\n\nexport const ImageRenderer: React.FC<FileRendererProps> = ({\n file,\n zoom,\n onZoomChange,\n fitRequest,\n onCapabilitiesChange,\n}) => {\n const apiRef = React.useRef<ReactZoomPanPinchContentRef | null>(null)\n const imgRef = React.useRef<HTMLImageElement | null>(null)\n const containerRef = React.useRef<HTMLDivElement | null>(null)\n const [loaded, setLoaded] = React.useState(false)\n // 2026-05-16 Round 5 codex audit fix:capture image-load rAF ID + cancel on unmount\n // (原 uncancelled rAF 可能 unmount 後 fire onZoomChange → setState 後 component 已 gone)\n const handleImageLoadRafIdRef = React.useRef<number>(0)\n React.useEffect(() => () => { if (handleImageLoadRafIdRef.current) cancelAnimationFrame(handleImageLoadRafIdRef.current) }, [])\n // Q2 RWD:track「user 最後一次的 zoom 意圖」— fit-page / fit-width 自動 reflow,manual 不動。\n // 對齊 Apple Photos / Drive canonical:resize 時若 user 在 fit mode → recompute,manual zoom → 維持。\n const lastFitModeRef = React.useRef<FitMode | 'manual'>('fit-page')\n // 區分「user wheel/pinch」vs「programmatic centerView」— lib onTransform 兩者都觸發,\n // 用 flag 防 programmatic 的 onTransform 誤標 mode = manual。\n const programmaticZoomRef = React.useRef(false)\n\n // 宣告 capability — shell 用此決定 toolbar 內容。\n React.useEffect(() => {\n onCapabilitiesChange({ zoom: true })\n }, [onCapabilitiesChange])\n\n // file.url 切換 → reset state,等 onLoad 重 fit。\n // 原本 bug:cache 命中時 onLoad 不 fire → handleImageLoad 不跑 → zoom 卡上一張的值\n // (或 shell 設的 100%)→ user 看到「同一張圖每次切過來尺寸不一致」。\n React.useEffect(() => {\n setLoaded(false)\n lastFitModeRef.current = 'fit-page'\n // cache 命中(<img complete>)→ onLoad 可能不 fire,直接觸發 handleImageLoad 邏輯\n const img = imgRef.current\n if (img && img.complete && img.naturalWidth > 0) {\n // 等下一個 microtask,確保 ref / state 都到位\n Promise.resolve().then(() => handleImageLoad())\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [file.url])\n\n // 算 fit scale(container 寬高 / image natural 寬高)\n const computeFitScale = React.useCallback((fit: FitMode): number | null => {\n const img = imgRef.current\n const container = containerRef.current\n if (!img || !container) return null\n if (!img.naturalWidth || !img.naturalHeight) return null\n const cw = container.clientWidth\n const ch = container.clientHeight\n if (cw <= 0 || ch <= 0) return null\n const widthRatio = cw / img.naturalWidth\n const heightRatio = ch / img.naturalHeight\n // fit-width = 寬填滿;fit-page = 完整可見(取較小 scale)\n return fit === 'fit-width' ? widthRatio : Math.min(widthRatio, heightRatio)\n }, [])\n\n const clampToPct = React.useCallback((scale: number): number => {\n const clamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))\n // Floor 而非 round:fit-to-page / fit-to-width 時 scale 是 float(e.g. 0.8356)。\n // round(0.8356 * 100) = 84 → 實際 scale 0.84 → image 比 canvas 大 4px,垂直溢出破壞\n // 對稱置中(user 抓:「上下邊距不對稱」)。Floor → 0.83 → image 比 canvas 小,永遠\n // 完整可見 + 視覺 symmetric padding。代價是最多 ~1% 的空間餘量,視覺幾乎看不出。\n return Math.floor(clamped * 100)\n }, [])\n\n // Image onLoad → 自動 fit-to-page(世界級開圖預設)\n const handleImageLoad = React.useCallback(() => {\n setLoaded(true)\n const scale = computeFitScale('fit-page')\n if (scale == null) return\n const pct = clampToPct(scale)\n lastFitModeRef.current = 'fit-page'\n // 等 transform 就緒再更新(避免 initialScale=1 → fit 過程跳兩段)\n if (handleImageLoadRafIdRef.current) cancelAnimationFrame(handleImageLoadRafIdRef.current)\n handleImageLoadRafIdRef.current = requestAnimationFrame(() => {\n handleImageLoadRafIdRef.current = 0\n onZoomChange(pct)\n })\n }, [computeFitScale, clampToPct, onZoomChange])\n\n // Q2 RWD:container resize 時若在 fit mode 重算 — 對齊 Apple Photos / Drive canonical\n // rAF debounce:drag window edge 期間 ResizeObserver 連續 fire 數十次,\n // 合併到下一 frame 只觸發一次 → 避免 race / 過多 centerView animation 互相打斷。\n React.useEffect(() => {\n if (!loaded) return\n const container = containerRef.current\n if (!container) return\n let rafId = 0\n // manual mode 視覺中心保持需要前次容器尺寸(比對 delta 反推新 position)\n let prevW = container.clientWidth\n let prevH = container.clientHeight\n const obs = new ResizeObserver(() => {\n if (rafId) cancelAnimationFrame(rafId)\n rafId = requestAnimationFrame(() => {\n rafId = 0\n const mode = lastFitModeRef.current\n const newW = container.clientWidth\n const newH = container.clientHeight\n if (mode === 'manual') {\n // 2026-07-08 Q2 補洞(user「畫布在各種情況都能完美適應」):manual zoom 下容器 resize\n //(InfoPanel 開合 / Filmstrip 增減 / 視窗 RWD / 裝置旋轉 — 同一咽喉點全涵蓋)\n // = **scale 不變、視覺中心點保持**(Figma / Photoshop canonical;原本 early-return\n // 什麼都不做 → transform 座標絕對值,容器變窄內容偏移)。\n // 幾何:舊容器中心對到的內容點 (cx,cy),resize 後把同一內容點放到新容器中心。\n const api = apiRef.current\n if (api && (newW !== prevW || newH !== prevH)) {\n const { positionX, positionY, scale } = api.state\n const cx = (prevW / 2 - positionX) / scale\n const cy = (prevH / 2 - positionY) / scale\n programmaticZoomRef.current = true\n api.setTransform(newW / 2 - cx * scale, newH / 2 - cy * scale, scale, 0)\n // setTransform 同步無動畫;下一 frame 解 flag(防 onTransform 誤標 manual→已是 manual,保守處理)\n requestAnimationFrame(() => { programmaticZoomRef.current = false })\n }\n prevW = newW\n prevH = newH\n return\n }\n prevW = newW\n prevH = newH\n const scale = computeFitScale(mode)\n if (scale == null) return\n onZoomChange(clampToPct(scale))\n })\n })\n obs.observe(container)\n return () => {\n obs.disconnect()\n if (rafId) cancelAnimationFrame(rafId)\n }\n }, [loaded, computeFitScale, clampToPct, onZoomChange])\n\n // Q3 雙擊 toggle fit ↔ 100%(對齊 Apple Photos / Preview.app / Imgur / PhotoSwipe canonical)\n const handleDoubleClick = React.useCallback(() => {\n if (!loaded) return\n const fitScale = computeFitScale('fit-page')\n if (fitScale == null) return\n const fitPct = clampToPct(fitScale)\n // 在 fit-page 附近(±5pt)→ 跳 100% natural;否則 → 回 fit-page\n const atFit = Math.abs(zoom - fitPct) < 5\n const targetPct = atFit ? 100 : fitPct\n lastFitModeRef.current = atFit ? 'manual' : 'fit-page' // 跳 100% = manual,回 fit = fit mode\n onZoomChange(targetPct)\n }, [loaded, zoom, computeFitScale, clampToPct, onZoomChange])\n\n // 外部 zoom 變動(preset / ± / 打字 / fit request)→ centerView 重定位\n // library canonical `centerView` 同時處理 scale + 置中 + animation + bounds。\n React.useEffect(() => {\n const api = apiRef.current\n if (!api || !loaded) return\n const currentScale = api.state.scale\n const targetScale = zoom / 100\n if (Math.abs(currentScale - targetScale) < 0.005) return\n // 標記 programmatic — onTransform 期間不要被誤標 manual mode\n programmaticZoomRef.current = true\n api.centerView(targetScale, 200)\n // 動畫 ~200ms + buffer 後解 flag\n const t = setTimeout(() => { programmaticZoomRef.current = false }, 280)\n return () => clearTimeout(t)\n }, [zoom, loaded])\n\n // Fit request(toolbar 菜單點 fit-width / fit-page)→ 算 scale emit 回 shell\n React.useEffect(() => {\n if (!fitRequest || !loaded) return\n const scale = computeFitScale(fitRequest.fit)\n if (scale == null) return\n lastFitModeRef.current = fitRequest.fit // 記 fit mode 給 ResizeObserver 用\n onZoomChange(clampToPct(scale))\n }, [fitRequest, loaded, computeFitScale, clampToPct, onZoomChange])\n\n // 內部 wheel / pinch zoom → 同步回 shell + 標記為 manual mode(打破 fit auto-reflow)\n // programmatic centerView 期間 lib 也會 fire onTransform → 用 flag 跳過,避免誤標 manual。\n const handleTransformed = React.useCallback(\n (_ref: ReactZoomPanPinchRef, state: { scale: number }) => {\n if (programmaticZoomRef.current) return // programmatic update,不標 manual\n const nextZoom = Math.round(state.scale * 100)\n if (nextZoom !== zoom) {\n lastFitModeRef.current = 'manual'\n onZoomChange(nextZoom)\n }\n },\n [zoom, onZoomChange],\n )\n\n return (\n <div ref={containerRef} className=\"w-full h-full overflow-hidden\" onDoubleClick={handleDoubleClick}>\n <TransformWrapper\n // TransformWrapper ref = ReactZoomPanPinchContentRef(dist/index.d.ts:351 + export L399);型別有 export → 用真型別,不需 as any\n ref={apiRef}\n initialScale={1}\n minScale={MIN_SCALE}\n maxScale={MAX_SCALE}\n centerOnInit\n centerZoomedOut\n // Teams 對標(2026-04-23):image viewer 走 chat-app lightbox 慣例 —\n // drag 時 image 保持在 canvas bounds 內(zoom-fit 時 drag 無意義,zoom-in 時 drag pan 有限制)。\n // `limitToBounds=true` 跟 Microsoft Teams / Slack / iOS Photos 等 chat-lightbox 互動一致,\n // 避免 Figma-canvas 式「可 drag 到任意位置」的無界體驗混淆 viewer 語境。\n limitToBounds={true}\n // Wheel zoom canonical:\n // - `step: 0.03` = 每 tick ~3% scale,對齊 Figma / Preview.app 細緻度\n // (原 0.1 = 10% 太粗,接近 Google Slides 離散慣例)\n // - wheel zoom = `scale + delta × step` 加法線性(handleCalculateWheelZoom,\n // dist/index.esm.js:1265;非 multiplicative/log —— 乘算 exp 只用於 setup smooth 的按鈕 zoom path)\n // 註:lib 型別無 `smoothStep` / `smoothScroll` key(僅 setup `smooth?:boolean` 走按鈕 exp、\n // 不影響 wheel);trackpad 平滑度目前無對應 wheel prop 可調\n wheel={{ step: 0.03 }}\n // Q3 雙擊改自定 handler:lib `mode: 'reset'` 永遠 reset 到 initialScale=1 → 100%,\n // 失去 fit ↔ 100% toggle UX(Apple Photos / Drive canonical)。disabled lib 預設 + 自定 onDoubleClick。\n doubleClick={{ disabled: true }}\n onTransform={handleTransformed}\n >\n {/* 2026-04-23 debug fix:contentClass 不設 `!w-full !h-full`。\n 設 `!w-full !h-full` 會讓 `.react-transform-component` 強制 1280×752 容器尺寸,\n 但 image 是 natural 1440×900(自然溢出 container)。Library `centerView(scale)`\n 基於 component 尺寸計算 translate → 計算偏 61px(視 image 被 WRAPPER 框住而非\n 自然 size)。\n 移除 content fixed size 後:component 自然 size = image natural → library 以\n image 實際尺寸計算置中,translate 正確(42.4, 2.5)得到 symmetric padding。\n wrapper 保留 `!w-full !h-full` 作 interaction capture bounds。 */}\n <TransformComponent wrapperClass=\"!w-full !h-full\">\n <img\n ref={imgRef}\n src={file.url}\n alt={file.name}\n onLoad={handleImageLoad}\n draggable={false}\n // natural size(**不走 object-contain**)— transform scale 管實際顯示大小\n className=\"max-w-none max-h-none select-none\"\n style={{ pointerEvents: 'none' }}\n />\n </TransformComponent>\n </TransformWrapper>\n </div>\n )\n}\nImageRenderer.displayName = 'ImageRenderer'\n\n// canRenderImage 已拆至 ./can-render-image(2026-07-06 D3 bundle fix)——\n// shell 需同步呼叫它,而本檔頂層 import react-zoom-pan-pinch;留在本檔會把整個 lib\n// 拉進 shell 的靜態 import graph,React.lazy 切分就失效。\n"],"names":["scale"],"mappings":";;;AAgCA,MAAM,YAAY;AAClB,MAAM,YAAY;AAIX,MAAM,gBAA6C,CAAC;AAAA,EACzD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,SAAS,MAAM,OAA2C,IAAI;AACpE,QAAM,SAAS,MAAM,OAAgC,IAAI;AACzD,QAAM,eAAe,MAAM,OAA8B,IAAI;AAC7D,QAAM,CAAC,QAAQ,SAAS,IAAI,MAAM,SAAS,KAAK;AAGhD,QAAM,0BAA0B,MAAM,OAAe,CAAC;AACtD,QAAM,UAAU,MAAM,MAAM;AAAE,QAAI,wBAAwB,QAAS,sBAAqB,wBAAwB,OAAO;AAAA,EAAE,GAAG,CAAA,CAAE;AAG9H,QAAM,iBAAiB,MAAM,OAA2B,UAAU;AAGlE,QAAM,sBAAsB,MAAM,OAAO,KAAK;AAG9C,QAAM,UAAU,MAAM;AACpB,yBAAqB,EAAE,MAAM,MAAM;AAAA,EACrC,GAAG,CAAC,oBAAoB,CAAC;AAKzB,QAAM,UAAU,MAAM;AACpB,cAAU,KAAK;AACf,mBAAe,UAAU;AAEzB,UAAM,MAAM,OAAO;AACnB,QAAI,OAAO,IAAI,YAAY,IAAI,eAAe,GAAG;AAE/C,cAAQ,QAAA,EAAU,KAAK,MAAM,iBAAiB;AAAA,IAChD;AAAA,EAEF,GAAG,CAAC,KAAK,GAAG,CAAC;AAGb,QAAM,kBAAkB,MAAM,YAAY,CAAC,QAAgC;AACzE,UAAM,MAAM,OAAO;AACnB,UAAM,YAAY,aAAa;AAC/B,QAAI,CAAC,OAAO,CAAC,UAAW,QAAO;AAC/B,QAAI,CAAC,IAAI,gBAAgB,CAAC,IAAI,cAAe,QAAO;AACpD,UAAM,KAAK,UAAU;AACrB,UAAM,KAAK,UAAU;AACrB,QAAI,MAAM,KAAK,MAAM,EAAG,QAAO;AAC/B,UAAM,aAAa,KAAK,IAAI;AAC5B,UAAM,cAAc,KAAK,IAAI;AAE7B,WAAO,QAAQ,cAAc,aAAa,KAAK,IAAI,YAAY,WAAW;AAAA,EAC5E,GAAG,CAAA,CAAE;AAEL,QAAM,aAAa,MAAM,YAAY,CAAC,UAA0B;AAC9D,UAAM,UAAU,KAAK,IAAI,WAAW,KAAK,IAAI,WAAW,KAAK,CAAC;AAK9D,WAAO,KAAK,MAAM,UAAU,GAAG;AAAA,EACjC,GAAG,CAAA,CAAE;AAGL,QAAM,kBAAkB,MAAM,YAAY,MAAM;AAC9C,cAAU,IAAI;AACd,UAAM,QAAQ,gBAAgB,UAAU;AACxC,QAAI,SAAS,KAAM;AACnB,UAAM,MAAM,WAAW,KAAK;AAC5B,mBAAe,UAAU;AAEzB,QAAI,wBAAwB,QAAS,sBAAqB,wBAAwB,OAAO;AACzF,4BAAwB,UAAU,sBAAsB,MAAM;AAC5D,8BAAwB,UAAU;AAClC,mBAAa,GAAG;AAAA,IAClB,CAAC;AAAA,EACH,GAAG,CAAC,iBAAiB,YAAY,YAAY,CAAC;AAK9C,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,OAAQ;AACb,UAAM,YAAY,aAAa;AAC/B,QAAI,CAAC,UAAW;AAChB,QAAI,QAAQ;AAEZ,QAAI,QAAQ,UAAU;AACtB,QAAI,QAAQ,UAAU;AACtB,UAAM,MAAM,IAAI,eAAe,MAAM;AACnC,UAAI,4BAA4B,KAAK;AACrC,cAAQ,sBAAsB,MAAM;AAClC,gBAAQ;AACR,cAAM,OAAO,eAAe;AAC5B,cAAM,OAAO,UAAU;AACvB,cAAM,OAAO,UAAU;AACvB,YAAI,SAAS,UAAU;AAMrB,gBAAM,MAAM,OAAO;AACnB,cAAI,QAAQ,SAAS,SAAS,SAAS,QAAQ;AAC7C,kBAAM,EAAE,WAAW,WAAW,OAAAA,OAAAA,IAAU,IAAI;AAC5C,kBAAM,MAAM,QAAQ,IAAI,aAAaA;AACrC,kBAAM,MAAM,QAAQ,IAAI,aAAaA;AACrC,gCAAoB,UAAU;AAC9B,gBAAI,aAAa,OAAO,IAAI,KAAKA,QAAO,OAAO,IAAI,KAAKA,QAAOA,QAAO,CAAC;AAEvE,kCAAsB,MAAM;AAAE,kCAAoB,UAAU;AAAA,YAAM,CAAC;AAAA,UACrE;AACA,kBAAQ;AACR,kBAAQ;AACR;AAAA,QACF;AACA,gBAAQ;AACR,gBAAQ;AACR,cAAM,QAAQ,gBAAgB,IAAI;AAClC,YAAI,SAAS,KAAM;AACnB,qBAAa,WAAW,KAAK,CAAC;AAAA,MAChC,CAAC;AAAA,IACH,CAAC;AACD,QAAI,QAAQ,SAAS;AACrB,WAAO,MAAM;AACX,UAAI,WAAA;AACJ,UAAI,4BAA4B,KAAK;AAAA,IACvC;AAAA,EACF,GAAG,CAAC,QAAQ,iBAAiB,YAAY,YAAY,CAAC;AAGtD,QAAM,oBAAoB,MAAM,YAAY,MAAM;AAChD,QAAI,CAAC,OAAQ;AACb,UAAM,WAAW,gBAAgB,UAAU;AAC3C,QAAI,YAAY,KAAM;AACtB,UAAM,SAAS,WAAW,QAAQ;AAElC,UAAM,QAAQ,KAAK,IAAI,OAAO,MAAM,IAAI;AACxC,UAAM,YAAY,QAAQ,MAAM;AAChC,mBAAe,UAAU,QAAQ,WAAW;AAC5C,iBAAa,SAAS;AAAA,EACxB,GAAG,CAAC,QAAQ,MAAM,iBAAiB,YAAY,YAAY,CAAC;AAI5D,QAAM,UAAU,MAAM;AACpB,UAAM,MAAM,OAAO;AACnB,QAAI,CAAC,OAAO,CAAC,OAAQ;AACrB,UAAM,eAAe,IAAI,MAAM;AAC/B,UAAM,cAAc,OAAO;AAC3B,QAAI,KAAK,IAAI,eAAe,WAAW,IAAI,KAAO;AAElD,wBAAoB,UAAU;AAC9B,QAAI,WAAW,aAAa,GAAG;AAE/B,UAAM,IAAI,WAAW,MAAM;AAAE,0BAAoB,UAAU;AAAA,IAAM,GAAG,GAAG;AACvE,WAAO,MAAM,aAAa,CAAC;AAAA,EAC7B,GAAG,CAAC,MAAM,MAAM,CAAC;AAGjB,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,cAAc,CAAC,OAAQ;AAC5B,UAAM,QAAQ,gBAAgB,WAAW,GAAG;AAC5C,QAAI,SAAS,KAAM;AACnB,mBAAe,UAAU,WAAW;AACpC,iBAAa,WAAW,KAAK,CAAC;AAAA,EAChC,GAAG,CAAC,YAAY,QAAQ,iBAAiB,YAAY,YAAY,CAAC;AAIlE,QAAM,oBAAoB,MAAM;AAAA,IAC9B,CAAC,MAA4B,UAA6B;AACxD,UAAI,oBAAoB,QAAS;AACjC,YAAM,WAAW,KAAK,MAAM,MAAM,QAAQ,GAAG;AAC7C,UAAI,aAAa,MAAM;AACrB,uBAAe,UAAU;AACzB,qBAAa,QAAQ;AAAA,MACvB;AAAA,IACF;AAAA,IACA,CAAC,MAAM,YAAY;AAAA,EAAA;AAGrB,6BACG,OAAA,EAAI,KAAK,cAAc,WAAU,iCAAgC,eAAe,mBAC/E,UAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MAEC,KAAK;AAAA,MACL,cAAc;AAAA,MACd,UAAU;AAAA,MACV,UAAU;AAAA,MACV,cAAY;AAAA,MACZ,iBAAe;AAAA,MAKf,eAAe;AAAA,MAQf,OAAO,EAAE,MAAM,KAAA;AAAA,MAGf,aAAa,EAAE,UAAU,KAAA;AAAA,MACzB,aAAa;AAAA,MAUb,UAAA,oBAAC,oBAAA,EAAmB,cAAa,mBAC/B,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,KAAK;AAAA,UACL,KAAK,KAAK;AAAA,UACV,KAAK,KAAK;AAAA,UACV,QAAQ;AAAA,UACR,WAAW;AAAA,UAEX,WAAU;AAAA,UACV,OAAO,EAAE,eAAe,OAAA;AAAA,QAAO;AAAA,MAAA,EACjC,CACF;AAAA,IAAA;AAAA,EAAA,GAEJ;AAEJ;AACA,cAAc,cAAc;"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hover-card.d.ts","sourceRoot":"","sources":["../../../src/components/HoverCard/hover-card.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,kBAAkB,MAAM,4BAA4B,CAAA;
|
|
1
|
+
{"version":3,"file":"hover-card.d.ts","sourceRoot":"","sources":["../../../src/components/HoverCard/hover-card.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,kBAAkB,MAAM,4BAA4B,CAAA;AAOhE;;;;;;;;;;GAUG;AAMH,QAAA,MAAM,SAAS,GAAI,qCAAoF,KAAK,CAAC,cAAc,CAAC,OAAO,kBAAkB,CAAC,IAAI,CAAC,4CAE1J,CAAA;AAED,QAAA,MAAM,gBAAgB,oHAA6B,CAAA;AAEnD,QAAA,MAAM,gBAAgB,oKAqCpB,CAAA;AAKF,eAAO,MAAM,aAAa;;;;;;;;;;;CAehB,CAAA;AAEV,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,CAAA"}
|
|
@@ -3,8 +3,9 @@ import * as React from "react";
|
|
|
3
3
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
|
4
4
|
import { cn } from "../../lib/utils.js";
|
|
5
5
|
import { OVERLAY_SIDE_OFFSET } from "../../tokens/elevation/overlay-geometry.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import { MOTION_DELAY_CLOSE_MS, MOTION_DELAY_RICH_MS } from "../../tokens/motion/motion.js";
|
|
7
|
+
import { overlayMotion } from "../../tokens/motion/overlay-motion.js";
|
|
8
|
+
const HoverCard = ({ openDelay = MOTION_DELAY_RICH_MS, closeDelay = MOTION_DELAY_CLOSE_MS, ...props }) => /* @__PURE__ */ jsx(HoverCardPrimitive.Root, { openDelay, closeDelay, ...props });
|
|
8
9
|
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
|
9
10
|
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = OVERLAY_SIDE_OFFSET, collisionPadding = 12, ...props }, ref) => (
|
|
10
11
|
// HoverCardPrimitive.Portal(2026-04-23):把 Content 搬到 `document.body`。
|
|
@@ -29,7 +30,8 @@ const HoverCardContent = React.forwardRef(({ className, align = "center", sideOf
|
|
|
29
30
|
"max-h-[var(--radix-hover-card-content-available-height,100vh)] flex flex-col overflow-hidden min-h-0",
|
|
30
31
|
// 2026-07-05 補 motion-reduce 豁免:對齊 overlay 家族 canonical(popover.tsx / dialog.tsx /
|
|
31
32
|
// dropdown-menu.tsx / tooltip.tsx 同位置寫法)— prefers-reduced-motion 下停用進出場動畫。
|
|
32
|
-
|
|
33
|
+
overlayMotion,
|
|
34
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
33
35
|
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
34
36
|
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
35
37
|
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hover-card.js","sources":["../../../src/components/HoverCard/hover-card.tsx"],"sourcesContent":["/**\n * @internal — DS-internal 單元(per `.claude/rules/ui-development.md` Public vs Internal canonical;spec frontmatter `isInternal`)。\n * 不進 root barrel front-door;由 Avatar(hoverCard)/ OverflowIndicator 等 DS 元件 wrap 消費,end-user app 請用 wrapper 元件。\n */\nimport * as React from \"react\"\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\"\n\nimport { cn } from \"@/lib/utils\"\nimport { OVERLAY_SIDE_OFFSET } from \"@/design-system/tokens/elevation/overlay-geometry\"\nimport {
|
|
1
|
+
{"version":3,"file":"hover-card.js","sources":["../../../src/components/HoverCard/hover-card.tsx"],"sourcesContent":["/**\n * @internal — DS-internal 單元(per `.claude/rules/ui-development.md` Public vs Internal canonical;spec frontmatter `isInternal`)。\n * 不進 root barrel front-door;由 Avatar(hoverCard)/ OverflowIndicator 等 DS 元件 wrap 消費,end-user app 請用 wrapper 元件。\n */\nimport * as React from \"react\"\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\"\n\nimport { cn } from \"@/lib/utils\"\nimport { OVERLAY_SIDE_OFFSET } from \"@/design-system/tokens/elevation/overlay-geometry\"\nimport { MOTION_DELAY_RICH_MS, MOTION_DELAY_CLOSE_MS } from \"@/design-system/tokens/motion/motion\"\nimport { overlayMotion } from \"@/design-system/tokens/motion/overlay-motion\"\n\n/**\n * HoverCard — hover 顯示可互動內容的浮層(行為 primitive)\n *\n * 跟 Tooltip 的差異:內容可互動(按鈕、連結、hover 子元素)。\n *\n * **不含視覺樣式**——bg、border、shadow、padding 由 consumer 決定:\n * - OverflowIndicator:深色 Tooltip 樣式(bg-tooltip + data-theme=\"dark\")\n * - ProfileCard:亮色 Card 樣式(bg-surface-raised + elevation-200)\n *\n * 只提供:z-index、動畫、sideOffset。\n */\n\n// 2026-06-11 user 拍板 A 案(「確保所有 hover 延遲都有按照設計原則」):Root 預設接 motion token SSOT\n// (rich 700ms open / close 200ms)— 對齊 tooltip.tsx 同 pattern(2026-05-18 拍板 #3A)。\n// 原裸用 Radix 預設 open 700 / close 300:open 巧合同值、close 300 ≠ canonical 200(drift)。\n// Consumer 仍可 per-instance override(Avatar 同值傳入、OverflowIndicator 傳 plain)。\nconst HoverCard = ({ openDelay = MOTION_DELAY_RICH_MS, closeDelay = MOTION_DELAY_CLOSE_MS, ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) => (\n <HoverCardPrimitive.Root openDelay={openDelay} closeDelay={closeDelay} {...props} />\n)\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger\n\nconst HoverCardContent = React.forwardRef<\n React.ElementRef<typeof HoverCardPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = OVERLAY_SIDE_OFFSET, collisionPadding = 12, ...props }, ref) => (\n // HoverCardPrimitive.Portal(2026-04-23):把 Content 搬到 `document.body`。\n // 不 Portal 時 Content 會 DOM-nested 在 trigger subtree,如 trigger 位於 OverflowIndicator\n // `data-theme=\"dark\"` tooltip 內部 → Avatar 自帶 HoverCard 的 Content 也卡在 dark subtree,\n // CSS var(--foreground) 繼承 dark 值 → ProfileCard 內部文字變 white 看不見(user 抓的 bug)。\n // Portal 到 body 讓 CSS 繼承 chain 從 app root data-theme 起算,不受 trigger subtree 污染。\n //\n // collisionPadding=12:Radix / browser 內部 1-2px rounding 讓 visual padding 比 prop 值少 1-2px。\n // 提高到 12 保證使用者實際看到 ≥ 8px viewport edge gap(overlay-surface「靠邊 8px」canonical)。\n <HoverCardPrimitive.Portal>\n <HoverCardPrimitive.Content\n ref={ref}\n align={align}\n sideOffset={sideOffset}\n collisionPadding={collisionPadding}\n className={cn(\n \"z-50 outline-none\",\n // 2026-05-04 viewport-aware max-h SSOT(對齊 Popover):header/footer 永遠 in-viewport,body 壓縮 scroll\n // 2026-05-05 audit dim 35 fix:加 `min-h-0` 完成 M25 chain invariant\n \"max-h-[var(--radix-hover-card-content-available-height,100vh)] flex flex-col overflow-hidden min-h-0\",\n // 2026-07-05 補 motion-reduce 豁免:對齊 overlay 家族 canonical(popover.tsx / dialog.tsx /\n // dropdown-menu.tsx / tooltip.tsx 同位置寫法)— prefers-reduced-motion 下停用進出場動畫。\n overlayMotion,\n \"data-[state=open]:animate-in data-[state=closed]:animate-out\",\n \"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n \"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n \"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2\",\n \"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n \"origin-[var(--radix-hover-card-content-transform-origin)]\",\n className,\n )}\n {...props}\n />\n </HoverCardPrimitive.Portal>\n))\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName\n\n// Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)\n// Phase 2 fill needed: purpose descriptions + when rationale + world-class refs\nexport const hoverCardMeta = {\n component: 'HoverCard',\n family: null, // non-family composite / overlay / layout\n variants: {\n\n },\n sizes: {\n\n },\n states: ['default'],\n tokens: {\n bg: ['bg-surface-raised'],\n fg: ['--foreground'],\n ring: [],\n },\n} as const\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent }\n"],"names":[],"mappings":";;;;;;;AA4BA,MAAM,YAAY,CAAC,EAAE,YAAY,sBAAsB,aAAa,uBAAuB,GAAG,MAAA,0BAC3F,mBAAmB,MAAnB,EAAwB,WAAsB,YAAyB,GAAG,MAAA,CAAO;AAGpF,MAAM,mBAAmB,mBAAmB;AAE5C,MAAM,mBAAmB,MAAM,WAG7B,CAAC,EAAE,WAAW,QAAQ,UAAU,aAAa,qBAAqB,mBAAmB,IAAI,GAAG,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASrG,oBAAC,mBAAmB,QAAnB,EACC,UAAA;AAAA,IAAC,mBAAmB;AAAA,IAAnB;AAAA,MACC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,WAAW;AAAA,QACT;AAAA;AAAA;AAAA,QAGA;AAAA;AAAA;AAAA,QAGA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MAAA;AAAA,MAED,GAAG;AAAA,IAAA;AAAA,EAAA,EACN,CACF;AAAA,CACD;AACD,iBAAiB,cAAc,mBAAmB,QAAQ;AAInD,MAAM,gBAAgB;AAAA,EAC3B,WAAW;AAAA,EACX,QAAQ;AAAA;AAAA,EACR,UAAU,CAAA;AAAA,EAGV,OAAO,CAAA;AAAA,EAGP,QAAQ,CAAC,SAAS;AAAA,EAClB,QAAQ;AAAA,IACN,IAAI,CAAC,mBAAmB;AAAA,IACxB,IAAI,CAAC,cAAc;AAAA,IACnB,MAAM,CAAA;AAAA,EAAC;AAEX;"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/InlineEdit/index.ts"],"names":[],"mappings":"AAIA,cAAc,eAAe,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { FieldSize } from '../../components/Field/field-context';
|
|
3
|
+
/** renderEdit 回呼收到的受控介面 —— consumer 換任意 Field 控件(Textarea / Select / DatePicker …)接線用 */
|
|
4
|
+
export interface InlineEditRenderProps<T = string> {
|
|
5
|
+
/** 目前編輯中的草稿值(內部管理,非已 commit 的 value) */
|
|
6
|
+
value: T;
|
|
7
|
+
/** 草稿變更 */
|
|
8
|
+
onChange: (next: T) => void;
|
|
9
|
+
/** 提交草稿 → onCommit(next) + 回 read 態 */
|
|
10
|
+
commit: () => void;
|
|
11
|
+
/** 放棄草稿 → 回 read 態不 commit */
|
|
12
|
+
cancel: () => void;
|
|
13
|
+
/** 進 edit 態時自動 focus 控件 */
|
|
14
|
+
autoFocus: boolean;
|
|
15
|
+
/** 尺寸(cascade 給控件,與 read 態高度對齊) */
|
|
16
|
+
size: FieldSize;
|
|
17
|
+
/** 控件無障礙標籤(= label prop) */
|
|
18
|
+
'aria-label'?: string;
|
|
19
|
+
}
|
|
20
|
+
type ReadTag = 'span' | 'p' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
21
|
+
export interface InlineEditProps<T = string> {
|
|
22
|
+
/** 已提交的欄位值(read 態顯示;edit 進入時作草稿初值)。泛型:string / number / string[] / PersonValue 等 */
|
|
23
|
+
value: T;
|
|
24
|
+
/** 提交回呼 —— blur / Enter 且草稿有變更時觸發 */
|
|
25
|
+
onCommit: (next: T) => void;
|
|
26
|
+
/**
|
|
27
|
+
* 自訂 edit 態控件(換 Textarea / Select / DatePicker 等 Field 控件)。
|
|
28
|
+
* 不傳則預設:multiline → `<Textarea>`,否則 `<Input>`(僅 T=string 時預設有效;非 string 值必傳此 prop)。
|
|
29
|
+
*/
|
|
30
|
+
renderEdit?: (props: InlineEditRenderProps<T>) => React.ReactNode;
|
|
31
|
+
/**
|
|
32
|
+
* 自訂 read 態格式化顯示。不傳則顯示 `String(value)`(空值顯 placeholder)。
|
|
33
|
+
* **SSOT 鐵律**:要顯示 Tag / 日期格式 / option label 等,**渲染對應控件的 `mode="display"`**
|
|
34
|
+
* (如 `renderRead={(v) => <Select mode="display" display="tag" value={v} options={opts} />}`),
|
|
35
|
+
* 不可自刻 —— 格式化 SSOT 住在該控件的 display mode(見檔頭)。
|
|
36
|
+
*/
|
|
37
|
+
renderRead?: (value: T) => React.ReactNode;
|
|
38
|
+
/** 多行欄位(Jira description 類):read 態換行 + 預設 edit 態用 `<Textarea>`,Enter=換行、Cmd/Ctrl+Enter 或 blur 提交 */
|
|
39
|
+
multiline?: boolean;
|
|
40
|
+
/** 欄位人類可讀名稱 —— 組 read 按鈕 aria-label(「編輯 {label}」)+ 控件 aria-label */
|
|
41
|
+
label?: string;
|
|
42
|
+
/** 空值時 read 態顯示的提示文字(fg-muted) */
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
/** 尺寸 —— read 態最小高度 + 預設 Input 尺寸(與 Field 家族一致) */
|
|
45
|
+
size?: FieldSize;
|
|
46
|
+
/** read 態語意標籤(標題場景傳 'h1'/'h2' 維持文件大綱;預設 'span') */
|
|
47
|
+
as?: ReadTag;
|
|
48
|
+
/** read 態文字樣式(如標題 `text-h4 font-bold`) */
|
|
49
|
+
readClassName?: string;
|
|
50
|
+
/** 外層容器 className */
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
declare const InlineEdit: (<T = string>(props: InlineEditProps<T> & {
|
|
54
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
55
|
+
}) => React.ReactElement) & {
|
|
56
|
+
displayName?: string;
|
|
57
|
+
};
|
|
58
|
+
export { InlineEdit };
|
|
59
|
+
//# sourceMappingURL=inline-edit.d.ts.map
|