Repository: webstudio-is/webstudio Branch: main Commit: 6c17a0ec191f Files: 2152 Total size: 10.0 MB Directory structure: gitextract_3aga18g3/ ├── .devcontainer/ │ ├── .gitignore │ ├── Dockerfile │ ├── devcontainer.json │ ├── docker-compose.yml │ ├── library-scripts/ │ │ └── docker-in-docker-debian.sh │ └── postinstall.sh ├── .editorconfig ├── .github/ │ ├── actions/ │ │ ├── add-status/ │ │ │ └── action.yaml │ │ ├── ci-setup/ │ │ │ └── action.yml │ │ ├── submodules-checkout/ │ │ │ └── action.yml │ │ └── vercel/ │ │ └── action.yaml │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── build-figma-tokens.yml │ ├── check-submodules.yml │ ├── chromatic.yml │ ├── cli-r2-static.yaml │ ├── cli-r2.yaml │ ├── delete-github-deployments.yml │ ├── fixtures-test.yml │ ├── lint-pull-request.yaml │ ├── main.yml │ ├── migrate.yaml │ ├── publish-beta.yml │ ├── re-create-figma-tokens-branch.yml │ ├── release.yml │ ├── vercel-deploy-staging.yml │ └── vis-reg-tests.yml ├── .gitignore ├── .gitmodules ├── .nvmrc ├── .oxlintrc.json ├── .prettierignore ├── .storybook/ │ ├── main.ts │ ├── preview-body.html │ └── preview.tsx ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── @types/ │ ├── canvas-iframe.d.ts │ ├── content.d.ts │ ├── css-tree.d.ts │ ├── navigator.d.ts │ ├── scroll-timeline.d.ts │ └── warn-once.d.ts ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── apps/ │ └── builder/ │ ├── .gitignore │ ├── app/ │ │ ├── auth/ │ │ │ ├── index.client.ts │ │ │ ├── login.stories.tsx │ │ │ ├── login.tsx │ │ │ └── secret-login.tsx │ │ ├── builder/ │ │ │ ├── builder.css │ │ │ ├── builder.tsx │ │ │ ├── features/ │ │ │ │ ├── address-bar.stories.tsx │ │ │ │ ├── address-bar.tsx │ │ │ │ ├── assets/ │ │ │ │ │ ├── assets.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── blocking-alerts/ │ │ │ │ │ ├── alert.stories.tsx │ │ │ │ │ ├── alert.tsx │ │ │ │ │ ├── blocking-alerts.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── breakpoints/ │ │ │ │ │ ├── breakpoint-editor-utils.test.ts │ │ │ │ │ ├── breakpoint-editor-utils.ts │ │ │ │ │ ├── breakpoints-container.tsx │ │ │ │ │ ├── breakpoints-editor.tsx │ │ │ │ │ ├── breakpoints-menu.tsx │ │ │ │ │ ├── breakpoints-selector.stories.tsx │ │ │ │ │ ├── breakpoints-selector.tsx │ │ │ │ │ ├── canvas-settings-popover.tsx │ │ │ │ │ ├── cascade-indicator.tsx │ │ │ │ │ ├── condition-input.tsx │ │ │ │ │ ├── confirmation-dialog.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── width-input.tsx │ │ │ │ ├── builder-mode.stories.tsx │ │ │ │ ├── builder-mode.tsx │ │ │ │ ├── clone.tsx │ │ │ │ ├── command-panel/ │ │ │ │ │ ├── command-panel.stories.tsx │ │ │ │ │ ├── command-panel.tsx │ │ │ │ │ ├── command-state.ts │ │ │ │ │ ├── groups/ │ │ │ │ │ │ ├── breakpoints-group.tsx │ │ │ │ │ │ ├── commands-group.tsx │ │ │ │ │ │ ├── components-group.tsx │ │ │ │ │ │ ├── convert-group.tsx │ │ │ │ │ │ ├── css-variables-group.tsx │ │ │ │ │ │ ├── data-variables-group.tsx │ │ │ │ │ │ ├── duplicate-tokens-group.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── instances-group.tsx │ │ │ │ │ │ ├── pages-group.tsx │ │ │ │ │ │ ├── tags-group.tsx │ │ │ │ │ │ ├── tokens-group.tsx │ │ │ │ │ │ ├── wrap-group.test.tsx │ │ │ │ │ │ └── wrap-group.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── shared/ │ │ │ │ │ ├── auto-select.ts │ │ │ │ │ ├── component-utils.ts │ │ │ │ │ ├── instance-list.tsx │ │ │ │ │ ├── instance-path-footer.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ └── usage-utils.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── components.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── use-draggable.tsx │ │ │ │ ├── footer/ │ │ │ │ │ ├── breadcrumbs.tsx │ │ │ │ │ ├── footer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── help/ │ │ │ │ │ ├── help-center.stories.tsx │ │ │ │ │ ├── help-center.tsx │ │ │ │ │ ├── remote-dialog.stories.tsx │ │ │ │ │ └── remote-dialog.tsx │ │ │ │ ├── keyboard-shortcuts-dialog/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── keyboard-shortcuts-dialog.stories.tsx │ │ │ │ │ └── keyboard-shortcuts-dialog.tsx │ │ │ │ ├── marketplace/ │ │ │ │ │ ├── about.stories.tsx │ │ │ │ │ ├── about.tsx │ │ │ │ │ ├── card.stories.tsx │ │ │ │ │ ├── card.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── marketplace.tsx │ │ │ │ │ ├── overview.tsx │ │ │ │ │ ├── templates.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── menu/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu-button.tsx │ │ │ │ │ ├── menu.stories.tsx │ │ │ │ │ └── menu.tsx │ │ │ │ ├── navigator/ │ │ │ │ │ ├── css-preview.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── navigator-tree.tsx │ │ │ │ │ └── navigator.tsx │ │ │ │ ├── pages/ │ │ │ │ │ ├── confirmation-dialogs.stories.tsx │ │ │ │ │ ├── confirmation-dialogs.tsx │ │ │ │ │ ├── custom-metadata.stories.tsx │ │ │ │ │ ├── custom-metadata.tsx │ │ │ │ │ ├── folder-settings.tsx │ │ │ │ │ ├── form.stories.tsx │ │ │ │ │ ├── form.tsx │ │ │ │ │ ├── image-info.stories.tsx │ │ │ │ │ ├── image-info.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── page-context-menu.tsx │ │ │ │ │ ├── page-settings.stories.tsx │ │ │ │ │ ├── page-settings.tsx │ │ │ │ │ ├── page-utils.test.ts │ │ │ │ │ ├── page-utils.ts │ │ │ │ │ ├── pages.tsx │ │ │ │ │ ├── search-preview.stories.tsx │ │ │ │ │ ├── search-preview.tsx │ │ │ │ │ ├── social-preview.stories.tsx │ │ │ │ │ ├── social-preview.tsx │ │ │ │ │ ├── social-utils.test.ts │ │ │ │ │ └── social-utils.ts │ │ │ │ ├── publish/ │ │ │ │ │ ├── add-domain.tsx │ │ │ │ │ ├── cname.test.ts │ │ │ │ │ ├── cname.ts │ │ │ │ │ ├── collapsible-domain-section.stories.tsx │ │ │ │ │ ├── collapsible-domain-section.tsx │ │ │ │ │ ├── domain-checkbox.tsx │ │ │ │ │ ├── domains.tsx │ │ │ │ │ ├── entri.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── publish.tsx │ │ │ │ ├── safe-mode.tsx │ │ │ │ ├── settings-panel/ │ │ │ │ │ ├── controls/ │ │ │ │ │ │ ├── boolean.tsx │ │ │ │ │ │ ├── check.tsx │ │ │ │ │ │ ├── code.tsx │ │ │ │ │ │ ├── combined.tsx │ │ │ │ │ │ ├── controls.stories.tsx │ │ │ │ │ │ ├── file.tsx │ │ │ │ │ │ ├── json.tsx │ │ │ │ │ │ ├── number.tsx │ │ │ │ │ │ ├── radio.tsx │ │ │ │ │ │ ├── resource-control.tsx │ │ │ │ │ │ ├── select-asset.tsx │ │ │ │ │ │ ├── select.tsx │ │ │ │ │ │ ├── tag-control.tsx │ │ │ │ │ │ ├── text-content.tsx │ │ │ │ │ │ ├── text.tsx │ │ │ │ │ │ └── url.tsx │ │ │ │ │ ├── curl.test.ts │ │ │ │ │ ├── curl.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── property-label.tsx │ │ │ │ │ ├── props-section/ │ │ │ │ │ │ ├── animation/ │ │ │ │ │ │ │ ├── animation-keyframes.tsx │ │ │ │ │ │ │ ├── animation-panel-content.stories.tsx │ │ │ │ │ │ │ ├── animation-panel-content.tsx │ │ │ │ │ │ │ ├── animation-section.tsx │ │ │ │ │ │ │ ├── animation-transforms.tsx │ │ │ │ │ │ │ ├── animations-select.tsx │ │ │ │ │ │ │ ├── keyframe-helpers.test.ts │ │ │ │ │ │ │ ├── keyframe-helpers.ts │ │ │ │ │ │ │ ├── new-scroll-animations.ts │ │ │ │ │ │ │ ├── new-view-animations.ts │ │ │ │ │ │ │ ├── set-css-property.test.tsx │ │ │ │ │ │ │ ├── set-css-property.ts │ │ │ │ │ │ │ └── subject-select.tsx │ │ │ │ │ │ ├── match-media-breakpoints.test.ts │ │ │ │ │ │ ├── match-media-breakpoints.ts │ │ │ │ │ │ ├── props-section.stories.tsx │ │ │ │ │ │ ├── props-section.tsx │ │ │ │ │ │ └── use-props-logic.ts │ │ │ │ │ ├── resource-panel.tsx │ │ │ │ │ ├── settings-panel.tsx │ │ │ │ │ ├── settings-section.tsx │ │ │ │ │ ├── shared.tsx │ │ │ │ │ ├── variable-popover.tsx │ │ │ │ │ ├── variables-section.stories.tsx │ │ │ │ │ └── variables-section.tsx │ │ │ │ ├── share.tsx │ │ │ │ ├── style-panel/ │ │ │ │ │ ├── controls/ │ │ │ │ │ │ ├── color/ │ │ │ │ │ │ │ └── color-control.tsx │ │ │ │ │ │ ├── font-family/ │ │ │ │ │ │ │ └── font-family-control.tsx │ │ │ │ │ │ ├── font-weight/ │ │ │ │ │ │ │ └── font-weight-control.tsx │ │ │ │ │ │ ├── image/ │ │ │ │ │ │ │ └── image-control.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── menu/ │ │ │ │ │ │ │ └── menu-control.tsx │ │ │ │ │ │ ├── position/ │ │ │ │ │ │ │ └── position-control.tsx │ │ │ │ │ │ ├── select/ │ │ │ │ │ │ │ └── select-control.tsx │ │ │ │ │ │ ├── text/ │ │ │ │ │ │ │ └── text-control.tsx │ │ │ │ │ │ ├── toggle/ │ │ │ │ │ │ │ └── toggle-control.tsx │ │ │ │ │ │ └── toggle-group/ │ │ │ │ │ │ └── toggle-group-control.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── property-label.tsx │ │ │ │ │ ├── sections/ │ │ │ │ │ │ ├── advanced/ │ │ │ │ │ │ │ ├── advanced.tsx │ │ │ │ │ │ │ └── stores.ts │ │ │ │ │ │ ├── backdrop-filter/ │ │ │ │ │ │ │ ├── backdrop-filter.stories.tsx │ │ │ │ │ │ │ └── backdrop-filter.tsx │ │ │ │ │ │ ├── backgrounds/ │ │ │ │ │ │ │ ├── background-code-editor.tsx │ │ │ │ │ │ │ ├── background-content.stories.tsx │ │ │ │ │ │ │ ├── background-content.tsx │ │ │ │ │ │ │ ├── background-gradient.tsx │ │ │ │ │ │ │ ├── background-image.tsx │ │ │ │ │ │ │ ├── background-position.tsx │ │ │ │ │ │ │ ├── background-size.test.ts │ │ │ │ │ │ │ ├── background-size.tsx │ │ │ │ │ │ │ ├── background-thumbnail.tsx │ │ │ │ │ │ │ ├── backgrounds.stories.tsx │ │ │ │ │ │ │ ├── backgrounds.tsx │ │ │ │ │ │ │ ├── gradient-utils.test.ts │ │ │ │ │ │ │ └── gradient-utils.ts │ │ │ │ │ │ ├── borders/ │ │ │ │ │ │ │ ├── border-color.tsx │ │ │ │ │ │ │ ├── border-property.tsx │ │ │ │ │ │ │ ├── border-radius.tsx │ │ │ │ │ │ │ ├── border-style.tsx │ │ │ │ │ │ │ ├── border-width.tsx │ │ │ │ │ │ │ ├── borders.stories.tsx │ │ │ │ │ │ │ ├── borders.tsx │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── box-shadows/ │ │ │ │ │ │ │ ├── box-shadows.stories.tsx │ │ │ │ │ │ │ └── box-shadows.tsx │ │ │ │ │ │ ├── filter/ │ │ │ │ │ │ │ ├── filter.stories.tsx │ │ │ │ │ │ │ └── filter.tsx │ │ │ │ │ │ ├── flex-child/ │ │ │ │ │ │ │ ├── flex-child.stories.tsx │ │ │ │ │ │ │ └── flex-child.tsx │ │ │ │ │ │ ├── grid-child/ │ │ │ │ │ │ │ ├── grid-child.stories.tsx │ │ │ │ │ │ │ ├── grid-child.test.ts │ │ │ │ │ │ │ └── grid-child.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── layout/ │ │ │ │ │ │ │ ├── layout.stories.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ └── shared/ │ │ │ │ │ │ │ ├── alignment-ui.stories.tsx │ │ │ │ │ │ │ ├── alignment-ui.test.ts │ │ │ │ │ │ │ ├── alignment-ui.tsx │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ ├── flex-alignment.tsx │ │ │ │ │ │ │ ├── grid-alignment.tsx │ │ │ │ │ │ │ ├── grid-area-picker.test.ts │ │ │ │ │ │ │ ├── grid-area-picker.tsx │ │ │ │ │ │ │ ├── grid-areas.test.ts │ │ │ │ │ │ │ ├── grid-areas.tsx │ │ │ │ │ │ │ ├── grid-areas.utils.test.ts │ │ │ │ │ │ │ ├── grid-generator.test.ts │ │ │ │ │ │ │ ├── grid-generator.tsx │ │ │ │ │ │ │ ├── grid-position-inputs.tsx │ │ │ │ │ │ │ ├── grid-settings.tsx │ │ │ │ │ │ │ ├── grid-utils.test.ts │ │ │ │ │ │ │ └── grid-utils.ts │ │ │ │ │ │ ├── list-item.tsx │ │ │ │ │ │ ├── outline/ │ │ │ │ │ │ │ ├── outline.stories.tsx │ │ │ │ │ │ │ └── outline.tsx │ │ │ │ │ │ ├── position/ │ │ │ │ │ │ │ ├── inset-control.stories.tsx │ │ │ │ │ │ │ ├── inset-control.tsx │ │ │ │ │ │ │ ├── inset-layout.stories.tsx │ │ │ │ │ │ │ ├── inset-layout.tsx │ │ │ │ │ │ │ ├── inset-tooltip.tsx │ │ │ │ │ │ │ └── position.tsx │ │ │ │ │ │ ├── sections.ts │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ ├── align-self.tsx │ │ │ │ │ │ │ ├── input-popover.tsx │ │ │ │ │ │ │ ├── keyboard.ts │ │ │ │ │ │ │ ├── order.tsx │ │ │ │ │ │ │ ├── scrub.tsx │ │ │ │ │ │ │ └── value-text.tsx │ │ │ │ │ │ ├── size/ │ │ │ │ │ │ │ ├── size.stories.tsx │ │ │ │ │ │ │ └── size.tsx │ │ │ │ │ │ ├── space/ │ │ │ │ │ │ │ ├── layout.stories.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── properties.ts │ │ │ │ │ │ │ ├── space.tsx │ │ │ │ │ │ │ └── tooltip.tsx │ │ │ │ │ │ ├── text-shadows/ │ │ │ │ │ │ │ ├── text-shadows.stories.tsx │ │ │ │ │ │ │ └── text-shadows.tsx │ │ │ │ │ │ ├── transforms/ │ │ │ │ │ │ │ ├── transform-and-perspective-origin.tsx │ │ │ │ │ │ │ ├── transform-extractors.test.ts │ │ │ │ │ │ │ ├── transform-extractors.ts │ │ │ │ │ │ │ ├── transform-rotate.tsx │ │ │ │ │ │ │ ├── transform-scale.tsx │ │ │ │ │ │ │ ├── transform-skew.tsx │ │ │ │ │ │ │ ├── transform-translate.tsx │ │ │ │ │ │ │ ├── transform-utils.ts │ │ │ │ │ │ │ ├── transforms.stories.tsx │ │ │ │ │ │ │ └── transforms.tsx │ │ │ │ │ │ ├── transitions/ │ │ │ │ │ │ │ ├── transition-content.tsx │ │ │ │ │ │ │ ├── transition-property.tsx │ │ │ │ │ │ │ ├── transitions.stories.tsx │ │ │ │ │ │ │ └── transitions.tsx │ │ │ │ │ │ └── typography/ │ │ │ │ │ │ ├── typography.stories.tsx │ │ │ │ │ │ └── typography.tsx │ │ │ │ │ ├── shared/ │ │ │ │ │ │ ├── color-picker.tsx │ │ │ │ │ │ ├── css-fragment.test.ts │ │ │ │ │ │ ├── css-fragment.tsx │ │ │ │ │ │ ├── css-value-input/ │ │ │ │ │ │ │ ├── convert-units.test.ts │ │ │ │ │ │ │ ├── convert-units.ts │ │ │ │ │ │ │ ├── css-value-input-container.tsx │ │ │ │ │ │ │ ├── css-value-input.stories.tsx │ │ │ │ │ │ │ ├── css-value-input.tsx │ │ │ │ │ │ │ ├── evaluate-math.test.ts │ │ │ │ │ │ │ ├── evaluate-math.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── parse-intermediate-or-invalid-value.ts │ │ │ │ │ │ │ ├── parse-intermediate-or-invalid-value.ts.test.ts │ │ │ │ │ │ │ ├── unit-select-options.ts │ │ │ │ │ │ │ ├── unit-select.test.ts │ │ │ │ │ │ │ ├── unit-select.tsx │ │ │ │ │ │ │ └── value-editor-dialog.tsx │ │ │ │ │ │ ├── filter-content.tsx │ │ │ │ │ │ ├── instances-kv.ts │ │ │ │ │ │ ├── model.tsx │ │ │ │ │ │ ├── modifier-keys.ts │ │ │ │ │ │ ├── recent-selectors.ts │ │ │ │ │ │ ├── repeated-style.test.ts │ │ │ │ │ │ ├── repeated-style.tsx │ │ │ │ │ │ ├── scroll-by-pointer.ts │ │ │ │ │ │ ├── shadow-content.tsx │ │ │ │ │ │ ├── show-more.stories.tsx │ │ │ │ │ │ ├── show-more.tsx │ │ │ │ │ │ ├── style-section.tsx │ │ │ │ │ │ └── use-style-data.ts │ │ │ │ │ ├── style-panel.tsx │ │ │ │ │ ├── style-source/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── style-source-badge.stories.tsx │ │ │ │ │ │ ├── style-source-badge.tsx │ │ │ │ │ │ ├── style-source-control.tsx │ │ │ │ │ │ ├── style-source-input.stories.tsx │ │ │ │ │ │ ├── style-source-input.tsx │ │ │ │ │ │ ├── style-source-menu.tsx │ │ │ │ │ │ └── use-sortable.tsx │ │ │ │ │ ├── style-source-section.test.ts │ │ │ │ │ └── style-source-section.tsx │ │ │ │ ├── sync-status.tsx │ │ │ │ ├── view-mode.tsx │ │ │ │ └── workspace/ │ │ │ │ ├── canvas-iframe.tsx │ │ │ │ ├── canvas-tools/ │ │ │ │ │ ├── apply-scale.ts │ │ │ │ │ ├── block-editor-context-menu.tsx │ │ │ │ │ ├── canvas-instance-context-menu.tsx │ │ │ │ │ ├── canvas-tools.tsx │ │ │ │ │ ├── grid-guides.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── media-badge.tsx │ │ │ │ │ ├── outline/ │ │ │ │ │ │ ├── block-instance-outline.tsx │ │ │ │ │ │ ├── block-utils.ts │ │ │ │ │ │ ├── collaborative-instance-outline.tsx │ │ │ │ │ │ ├── hovered-instance-outline.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── label.tsx │ │ │ │ │ │ ├── outline.stories.tsx │ │ │ │ │ │ ├── outline.tsx │ │ │ │ │ │ └── selected-instance-outline.tsx │ │ │ │ │ ├── resize-handles.tsx │ │ │ │ │ ├── text-toolbar.tsx │ │ │ │ │ └── use-subscribe-drag-drop-state.ts │ │ │ │ ├── index.ts │ │ │ │ └── workspace.tsx │ │ │ ├── index.client.ts │ │ │ ├── inspector.tsx │ │ │ ├── shared/ │ │ │ │ ├── asset-manager/ │ │ │ │ │ ├── asset-filters.tsx │ │ │ │ │ ├── asset-info.test.ts │ │ │ │ │ ├── asset-info.tsx │ │ │ │ │ ├── asset-manager.stories.tsx │ │ │ │ │ ├── asset-manager.tsx │ │ │ │ │ ├── asset-sort.tsx │ │ │ │ │ ├── asset-thumbnail.tsx │ │ │ │ │ ├── delete-unused-assets.tsx │ │ │ │ │ ├── image.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── uploading-animation.tsx │ │ │ │ │ ├── utils.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── assets/ │ │ │ │ │ ├── asset-upload.test.ts │ │ │ │ │ ├── asset-upload.tsx │ │ │ │ │ ├── asset-utils.test.ts │ │ │ │ │ ├── asset-utils.ts │ │ │ │ │ ├── assets-shell.tsx │ │ │ │ │ ├── delete-assets.ts │ │ │ │ │ ├── drag-monitor.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ ├── separator.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── upload-assets.test.ts │ │ │ │ │ ├── upload-assets.tsx │ │ │ │ │ ├── use-assets.test.ts │ │ │ │ │ └── use-assets.tsx │ │ │ │ ├── binding-popover.test.ts │ │ │ │ ├── binding-popover.tsx │ │ │ │ ├── calc-canvas-width.test.ts │ │ │ │ ├── calc-canvas-width.ts │ │ │ │ ├── client-settings/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── settings.ts │ │ │ │ ├── collapsible-section.stories.tsx │ │ │ │ ├── collapsible-section.tsx │ │ │ │ ├── commands.ts │ │ │ │ ├── css-editor/ │ │ │ │ │ ├── add-style-input.tsx │ │ │ │ │ ├── css-editor-context-menu.tsx │ │ │ │ │ ├── css-editor.stories.tsx │ │ │ │ │ ├── css-editor.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── parse-style-input.test.ts │ │ │ │ │ └── parse-style-input.ts │ │ │ │ ├── css-variable-utils.test.tsx │ │ │ │ ├── css-variable-utils.tsx │ │ │ │ ├── data-variable-utils.test.tsx │ │ │ │ ├── data-variable-utils.tsx │ │ │ │ ├── expression-editor.stories.tsx │ │ │ │ ├── expression-editor.test.ts │ │ │ │ ├── expression-editor.tsx │ │ │ │ ├── fonts-manager/ │ │ │ │ │ ├── fonts-manager.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── item-menu.tsx │ │ │ │ │ └── item-utils.ts │ │ │ │ ├── inert-handlers.ts │ │ │ │ ├── instance-context-menu.tsx │ │ │ │ ├── instance-label.tsx │ │ │ │ ├── loading.stories.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── nano-states.ts │ │ │ │ ├── relative-time.stories.tsx │ │ │ │ ├── relative-time.tsx │ │ │ │ ├── style-source-actions.tsx │ │ │ │ ├── topbar-layout.stories.tsx │ │ │ │ ├── topbar-layout.tsx │ │ │ │ ├── topbar.tsx │ │ │ │ ├── url-pattern.test.ts │ │ │ │ ├── url-pattern.ts │ │ │ │ └── use-disable-context-menu.ts │ │ │ └── sidebar-left/ │ │ │ ├── sidebar-left.tsx │ │ │ ├── sidebar-tabs.tsx │ │ │ └── types.ts │ │ ├── canvas/ │ │ │ ├── canvas.tsx │ │ │ ├── collaborative-instance.ts │ │ │ ├── elements.tsx │ │ │ ├── features/ │ │ │ │ ├── build-mode/ │ │ │ │ │ ├── block-template.tsx │ │ │ │ │ └── block.tsx │ │ │ │ ├── text-editor/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── interop.test.tsx │ │ │ │ │ ├── interop.ts │ │ │ │ │ ├── text-editor.stories.tsx │ │ │ │ │ ├── text-editor.tsx │ │ │ │ │ └── toolbar-connector.tsx │ │ │ │ └── webstudio-component/ │ │ │ │ ├── index.ts │ │ │ │ ├── webstudio-component.test.tsx │ │ │ │ └── webstudio-component.tsx │ │ │ ├── grid-guide-utils.test.ts │ │ │ ├── grid-guide-utils.ts │ │ │ ├── index.client.ts │ │ │ ├── inflator.test.ts │ │ │ ├── inflator.ts │ │ │ ├── instance-context-menu.ts │ │ │ ├── instance-hovering.ts │ │ │ ├── instance-selected.ts │ │ │ ├── instance-selection.ts │ │ │ ├── interceptor.ts │ │ │ ├── scrollbar-width.ts │ │ │ ├── shared/ │ │ │ │ ├── commands.ts │ │ │ │ ├── font-weight-support.ts │ │ │ │ ├── inert.ts │ │ │ │ ├── routing-priority.test.ts │ │ │ │ ├── routing-priority.ts │ │ │ │ ├── scroll-new-instance-into-view.ts │ │ │ │ ├── scroll-state.ts │ │ │ │ ├── styles.test.ts │ │ │ │ ├── styles.ts │ │ │ │ ├── use-drag-drop.ts │ │ │ │ └── use-pointer-outline.ts │ │ │ └── stores.ts │ │ ├── dashboard/ │ │ │ ├── dashboard.stories.tsx │ │ │ ├── dashboard.tsx │ │ │ ├── index.client.ts │ │ │ ├── profile-menu.tsx │ │ │ ├── projects/ │ │ │ │ ├── colors.ts │ │ │ │ ├── project-card.tsx │ │ │ │ ├── project-dialogs.tsx │ │ │ │ ├── project-menu.tsx │ │ │ │ ├── projects-list.tsx │ │ │ │ ├── projects.tsx │ │ │ │ ├── sort.test.ts │ │ │ │ ├── sort.tsx │ │ │ │ ├── tags.tsx │ │ │ │ └── utils.ts │ │ │ ├── search/ │ │ │ │ ├── nothing-found.tsx │ │ │ │ ├── search-field.tsx │ │ │ │ └── search-results.tsx │ │ │ ├── shared/ │ │ │ │ ├── card.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── spinner.tsx │ │ │ │ ├── thumbnail.tsx │ │ │ │ └── types.ts │ │ │ └── templates/ │ │ │ ├── template-card.tsx │ │ │ └── templates.tsx │ │ ├── env/ │ │ │ ├── env.server.ts │ │ │ ├── env.static.server.ts │ │ │ ├── env.static.ts │ │ │ └── vite-env.d.ts │ │ ├── root.tsx │ │ ├── routes/ │ │ │ ├── _canvas.canvas.tsx │ │ │ ├── _canvas.tsx │ │ │ ├── _ui.$.tsx │ │ │ ├── _ui.(builder).tsx │ │ │ ├── _ui.dashboard._index.tsx │ │ │ ├── _ui.dashboard.search.tsx │ │ │ ├── _ui.dashboard.templates.tsx │ │ │ ├── _ui.dashboard.tsx │ │ │ ├── _ui.error.tsx │ │ │ ├── _ui.login._index.tsx │ │ │ ├── _ui.logout.tsx │ │ │ ├── _ui.tsx │ │ │ ├── auth.dev.tsx │ │ │ ├── auth.github.tsx │ │ │ ├── auth.github_.callback.tsx │ │ │ ├── auth.google.tsx │ │ │ ├── auth.google_.callback.tsx │ │ │ ├── auth.ws.ts │ │ │ ├── auth.ws_.callback.ts │ │ │ ├── builder-logout.ts │ │ │ ├── cgi.asset.$.ts │ │ │ ├── cgi.empty[.]gif.ts │ │ │ ├── cgi.image.$.ts │ │ │ ├── cgi.video.$.ts │ │ │ ├── dashboard-logout.ts │ │ │ ├── n8n.$.tsx │ │ │ ├── oauth.ws.authorize.tsx │ │ │ ├── oauth.ws.token.ts │ │ │ ├── rest.assets.tsx │ │ │ ├── rest.assets_.$name.tsx │ │ │ ├── rest.build.$buildId.tsx │ │ │ ├── rest.buildId.$projectId.tsx │ │ │ ├── rest.data.$projectId.ts │ │ │ ├── rest.patch.ts │ │ │ ├── rest.resources-loader.ts │ │ │ └── trpc.$.ts │ │ ├── services/ │ │ │ ├── auth-strategy/ │ │ │ │ └── ws.server.ts │ │ │ ├── auth.server.ts │ │ │ ├── auth.server.utils.ts │ │ │ ├── bloom-filter.server.test.ts │ │ │ ├── bloom-filter.server.ts │ │ │ ├── builder-access.server.ts │ │ │ ├── builder-auth.server.ts │ │ │ ├── builder-session.server.ts │ │ │ ├── cookie.server.ts │ │ │ ├── csrf-session.server.ts │ │ │ ├── destinations.server.ts │ │ │ ├── logout-router.server.ts │ │ │ ├── no-cross-origin-cookie.ts │ │ │ ├── no-store-redirect.ts │ │ │ ├── session.server.ts │ │ │ ├── token.server.test.ts │ │ │ ├── token.server.ts │ │ │ ├── trcp-router.server.ts │ │ │ ├── trpc.server.ts │ │ │ └── user-router.server.ts │ │ └── shared/ │ │ ├── $resources/ │ │ │ ├── assets.server.ts │ │ │ ├── current-date.server.ts │ │ │ └── sitemap.xml.server.ts │ │ ├── app.css │ │ ├── array-utils.test.ts │ │ ├── array-utils.ts │ │ ├── asset-client.ts │ │ ├── awareness.test.tsx │ │ ├── awareness.ts │ │ ├── breakpoints/ │ │ │ ├── index.ts │ │ │ ├── select-breakpoint-by-order.ts │ │ │ └── stores.ts │ │ ├── breakpoints-utils.test.ts │ │ ├── breakpoints-utils.ts │ │ ├── builder-api.ts │ │ ├── builder-data.ts │ │ ├── canvas-api.ts │ │ ├── client-only.ts │ │ ├── client-supports.ts │ │ ├── clone-project.tsx │ │ ├── code-editor-base.tsx │ │ ├── code-editor.stories.tsx │ │ ├── code-editor.tsx │ │ ├── code-highlight.ts │ │ ├── commands-emitter.ts │ │ ├── content-model.test.tsx │ │ ├── content-model.ts │ │ ├── context.server.ts │ │ ├── copy-paste/ │ │ │ ├── asset-upload.test.tsx │ │ │ ├── asset-upload.ts │ │ │ ├── init-copy-paste.ts │ │ │ ├── plugin-html.test.tsx │ │ │ ├── plugin-html.ts │ │ │ ├── plugin-instance.test.ts │ │ │ ├── plugin-instance.ts │ │ │ ├── plugin-markdown.test.tsx │ │ │ ├── plugin-markdown.ts │ │ │ └── plugin-webflow/ │ │ │ ├── __generated__/ │ │ │ │ └── style-presets.ts │ │ │ ├── instances-properties.ts │ │ │ ├── plugin-webflow.test.tsx │ │ │ ├── plugin-webflow.ts │ │ │ ├── schema.ts │ │ │ ├── style-presets-overrides.ts │ │ │ ├── style-presets.css │ │ │ └── styles.ts │ │ ├── copy-paste.test.tsx │ │ ├── copy-to-clipboard.stories.tsx │ │ ├── copy-to-clipboard.tsx │ │ ├── csrf.client.ts │ │ ├── data-variables.test.tsx │ │ ├── data-variables.ts │ │ ├── db/ │ │ │ ├── canvas.server.ts │ │ │ ├── index.ts │ │ │ ├── user-plan-features.server.ts │ │ │ └── user.server.ts │ │ ├── debug-track.ts │ │ ├── debug.ts │ │ ├── dom-hooks/ │ │ │ ├── index.ts │ │ │ ├── use-content-editable.ts │ │ │ └── use-window-resize.ts │ │ ├── dom-utils.stories.tsx │ │ ├── dom-utils.test.ts │ │ ├── dom-utils.ts │ │ ├── empty.ts │ │ ├── entri/ │ │ │ └── entri-api.server.ts │ │ ├── error/ │ │ │ ├── error-boundary.tsx │ │ │ ├── error-message.client.tsx │ │ │ ├── error-message.stories.tsx │ │ │ ├── error-parse.ts │ │ │ ├── index.ts │ │ │ └── toast-error.tsx │ │ ├── event-utils.test.ts │ │ ├── event-utils.ts │ │ ├── fetch.client.ts │ │ ├── form-utils/ │ │ │ ├── index.ts │ │ │ └── use-ids.ts │ │ ├── help.tsx │ │ ├── hook-utils/ │ │ │ ├── effect-event.ts │ │ │ ├── use-interval.ts │ │ │ └── use-mount.ts │ │ ├── html.test.tsx │ │ ├── html.ts │ │ ├── instance-utils.test.tsx │ │ ├── instance-utils.ts │ │ ├── logout.client.tsx │ │ ├── marketplace/ │ │ │ ├── db.server.ts │ │ │ ├── router.server.ts │ │ │ └── types.ts │ │ ├── matcher.test.tsx │ │ ├── matcher.ts │ │ ├── math-utils.ts │ │ ├── nano-hash.test.ts │ │ ├── nano-hash.ts │ │ ├── nano-states/ │ │ │ ├── breakpoints.ts │ │ │ ├── canvas.ts │ │ │ ├── components.ts │ │ │ ├── index.ts │ │ │ ├── instances.ts │ │ │ ├── misc.ts │ │ │ ├── pages.ts │ │ │ ├── project-settings.ts │ │ │ ├── props.test.tsx │ │ │ ├── props.ts │ │ │ └── variables.ts │ │ ├── page-utils.test.tsx │ │ ├── page-utils.ts │ │ ├── pages/ │ │ │ ├── index.ts │ │ │ └── use-switch-page.ts │ │ ├── project-settings/ │ │ │ ├── image-control.tsx │ │ │ ├── import-redirects-dialog.stories.tsx │ │ │ ├── import-redirects-dialog.tsx │ │ │ ├── index.ts │ │ │ ├── project-settings.stories.tsx │ │ │ ├── project-settings.tsx │ │ │ ├── section-backups.tsx │ │ │ ├── section-general.tsx │ │ │ ├── section-marketplace.tsx │ │ │ ├── section-publish.tsx │ │ │ ├── section-redirects.test.ts │ │ │ ├── section-redirects.tsx │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ │ ├── pubsub/ │ │ │ ├── create.test.ts │ │ │ ├── create.ts │ │ │ ├── index.ts │ │ │ └── raf-queue.ts │ │ ├── redirects/ │ │ │ ├── README.md │ │ │ ├── fixtures/ │ │ │ │ ├── apache.htaccess │ │ │ │ ├── generic.csv │ │ │ │ ├── generic.json │ │ │ │ ├── hubspot.csv │ │ │ │ ├── netlify._redirects │ │ │ │ ├── shopify.csv │ │ │ │ └── vercel-nextjs.json │ │ │ ├── redirect-loop-detection.test.ts │ │ │ ├── redirect-loop-detection.ts │ │ │ ├── redirect-parsers.test.ts │ │ │ └── redirect-parsers.ts │ │ ├── resource-utils.ts │ │ ├── resources.test.ts │ │ ├── resources.ts │ │ ├── router-utils/ │ │ │ ├── index.ts │ │ │ ├── is-canvas.ts │ │ │ ├── origins.ts │ │ │ └── path-utils.ts │ │ ├── session/ │ │ │ ├── index.ts │ │ │ └── use-login-error-message.ts │ │ ├── share-project/ │ │ │ ├── index.ts │ │ │ ├── share-project-container.tsx │ │ │ ├── share-project.stories.tsx │ │ │ └── share-project.tsx │ │ ├── shim.test.ts │ │ ├── shim.ts │ │ ├── store-utils.test.ts │ │ ├── store-utils.ts │ │ ├── string-utils.ts │ │ ├── style-object-model.test.tsx │ │ ├── style-object-model.ts │ │ ├── style-source-utils.test.tsx │ │ ├── style-source-utils.ts │ │ ├── sync/ │ │ │ ├── command-queue.ts │ │ │ ├── data-stores.ts │ │ │ ├── project-queue.ts │ │ │ ├── sync-client.ts │ │ │ └── sync-stores.ts │ │ ├── sync-client.test.ts │ │ ├── sync-client.ts │ │ ├── system.test.ts │ │ ├── system.ts │ │ ├── tailwind/ │ │ │ ├── __generated__/ │ │ │ │ └── preflight.ts │ │ │ ├── preflight-bin.ts │ │ │ ├── preflight.css │ │ │ ├── tailwind.test.tsx │ │ │ └── tailwind.ts │ │ ├── token-conflict-dialog.tsx │ │ ├── tree-utils.test.ts │ │ ├── tree-utils.ts │ │ ├── trpc/ │ │ │ └── trpc-client.ts │ │ ├── use-set-features.ts │ │ ├── visually-hidden.ts │ │ ├── webstudio-data-migrator.test.ts │ │ └── webstudio-data-migrator.ts │ ├── docker-compose.yaml │ ├── docs/ │ │ └── test-cases.md │ ├── package.json │ ├── public/ │ │ └── robots.txt │ ├── tsconfig.json │ ├── vite.config.ts │ └── vitest.config.ts ├── codemod/ │ ├── migrate-css-variables.ts │ └── migrate-tokens.ts ├── fixtures/ │ ├── README.md │ ├── react-router-cloudflare/ │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .template/ │ │ │ ├── .npmrc │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── .webstudio/ │ │ │ ├── config.json │ │ │ └── data.json │ │ ├── app/ │ │ │ ├── __generated__/ │ │ │ │ ├── $resources.assets.ts │ │ │ │ ├── $resources.sitemap.xml.ts │ │ │ │ ├── [another-page]._index.server.tsx │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── _index.server.tsx │ │ │ │ ├── _index.tsx │ │ │ │ └── index.css │ │ │ ├── constants.mjs │ │ │ ├── entry.server.tsx │ │ │ ├── extension.ts │ │ │ ├── root.tsx │ │ │ ├── routes/ │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── [robots.txt].tsx │ │ │ │ ├── [sitemap.xml]._index.tsx │ │ │ │ └── _index.tsx │ │ │ └── routes.ts │ │ ├── package.json │ │ ├── react-router.config.ts │ │ ├── tsconfig.json │ │ ├── vite.config.ts │ │ ├── workers/ │ │ │ └── app.ts │ │ └── wrangler.jsonc │ ├── react-router-docker/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .template/ │ │ │ ├── .npmrc │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── .webstudio/ │ │ │ ├── config.json │ │ │ └── data.json │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── app/ │ │ │ ├── __generated__/ │ │ │ │ ├── $resources.assets.ts │ │ │ │ ├── $resources.sitemap.xml.ts │ │ │ │ ├── [another-page]._index.server.tsx │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── _index.server.tsx │ │ │ │ ├── _index.tsx │ │ │ │ └── index.css │ │ │ ├── constants.mjs │ │ │ ├── extension.ts │ │ │ ├── root.tsx │ │ │ ├── routes/ │ │ │ │ ├── [_image].$.ts │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── [robots.txt].tsx │ │ │ │ ├── [sitemap.xml]._index.tsx │ │ │ │ └── _index.tsx │ │ │ └── routes.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── react-router-netlify/ │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .template/ │ │ │ ├── .npmrc │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── .webstudio/ │ │ │ ├── config.json │ │ │ └── data.json │ │ ├── app/ │ │ │ ├── __generated__/ │ │ │ │ ├── $resources.assets.ts │ │ │ │ ├── $resources.sitemap.xml.ts │ │ │ │ ├── [another-page]._index.server.tsx │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── _index.server.tsx │ │ │ │ ├── _index.tsx │ │ │ │ └── index.css │ │ │ ├── constants.mjs │ │ │ ├── extension.ts │ │ │ ├── root.tsx │ │ │ ├── routes/ │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── [robots.txt].tsx │ │ │ │ ├── [sitemap.xml]._index.tsx │ │ │ │ └── _index.tsx │ │ │ └── routes.ts │ │ ├── netlify.toml │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── react-router-vercel/ │ │ ├── .gitignore │ │ ├── .npmrc │ │ ├── .template/ │ │ │ ├── .npmrc │ │ │ ├── package.json │ │ │ └── tsconfig.json │ │ ├── .webstudio/ │ │ │ ├── config.json │ │ │ └── data.json │ │ ├── app/ │ │ │ ├── __generated__/ │ │ │ │ ├── $resources.assets.ts │ │ │ │ ├── $resources.sitemap.xml.ts │ │ │ │ ├── [another-page]._index.server.tsx │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── _index.server.tsx │ │ │ │ ├── _index.tsx │ │ │ │ └── index.css │ │ │ ├── constants.mjs │ │ │ ├── extension.ts │ │ │ ├── root.tsx │ │ │ ├── routes/ │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── [robots.txt].tsx │ │ │ │ ├── [sitemap.xml]._index.tsx │ │ │ │ └── _index.tsx │ │ │ └── routes.ts │ │ ├── package.json │ │ ├── react-router.config.ts │ │ ├── tsconfig.json │ │ ├── vercel.json │ │ └── vite.config.ts │ ├── ssg/ │ │ ├── .npmrc │ │ ├── .webstudio/ │ │ │ ├── config.json │ │ │ └── data.json │ │ ├── app/ │ │ │ ├── __generated__/ │ │ │ │ ├── $resources.assets.ts │ │ │ │ ├── $resources.sitemap.xml.ts │ │ │ │ ├── [another-page]._index.server.tsx │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── _index.server.tsx │ │ │ │ ├── _index.tsx │ │ │ │ └── index.css │ │ │ └── constants.mjs │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── +config.ts │ │ │ ├── another-page/ │ │ │ │ ├── +Head.tsx │ │ │ │ ├── +Page.tsx │ │ │ │ └── +data.ts │ │ │ └── index/ │ │ │ ├── +Head.tsx │ │ │ ├── +Page.tsx │ │ │ └── +data.ts │ │ ├── renderer/ │ │ │ ├── +onRenderClient.tsx │ │ │ └── +onRenderHtml.tsx │ │ ├── tsconfig.json │ │ ├── vike.d.ts │ │ └── vite.config.ts │ ├── ssg-netlify-by-project-id/ │ │ ├── .npmrc │ │ ├── .webstudio/ │ │ │ ├── config.json │ │ │ └── data.json │ │ ├── app/ │ │ │ ├── __generated__/ │ │ │ │ ├── $resources.assets.ts │ │ │ │ ├── $resources.sitemap.xml.ts │ │ │ │ ├── [redirect]._index.server.tsx │ │ │ │ ├── [redirect]._index.tsx │ │ │ │ ├── _index.server.tsx │ │ │ │ ├── _index.tsx │ │ │ │ └── index.css │ │ │ └── constants.mjs │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── +config.ts │ │ │ ├── index/ │ │ │ │ ├── +Head.tsx │ │ │ │ ├── +Page.tsx │ │ │ │ └── +data.ts │ │ │ └── redirect/ │ │ │ ├── +Head.tsx │ │ │ ├── +Page.tsx │ │ │ └── +data.ts │ │ ├── renderer/ │ │ │ ├── +onRenderClient.tsx │ │ │ └── +onRenderHtml.tsx │ │ ├── tsconfig.json │ │ ├── vike.d.ts │ │ └── vite.config.ts │ ├── webstudio-cloudflare-template/ │ │ ├── .npmrc │ │ ├── .webstudio/ │ │ │ ├── config.json │ │ │ └── data.json │ │ ├── WS_CF_README.md │ │ ├── app/ │ │ │ ├── __generated__/ │ │ │ │ ├── $resources.assets.ts │ │ │ │ ├── $resources.sitemap.xml.ts │ │ │ │ ├── [another-page]._index.server.tsx │ │ │ │ ├── [another-page]._index.tsx │ │ │ │ ├── _index.server.tsx │ │ │ │ ├── _index.tsx │ │ │ │ └── index.css │ │ │ ├── constants.mjs │ │ │ ├── extension.ts │ │ │ ├── root.tsx │ │ │ └── routes/ │ │ │ ├── [another-page]._index.tsx │ │ │ ├── [robots.txt].tsx │ │ │ ├── [sitemap.xml]._index.tsx │ │ │ └── _index.tsx │ │ ├── functions/ │ │ │ └── [[path]].ts │ │ ├── load-context.ts │ │ ├── package.json │ │ ├── tsconfig.json │ │ ├── vite.config.ts │ │ ├── worker-configuration.d.ts │ │ └── wrangler.toml │ └── webstudio-features/ │ ├── .gitignore │ ├── .npmrc │ ├── .template/ │ │ ├── .npmrc │ │ ├── app/ │ │ │ └── constants.mjs │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── .webstudio/ │ │ ├── config.json │ │ └── data.json │ ├── README.md │ ├── app/ │ │ ├── __generated__/ │ │ │ ├── $resources.assets.ts │ │ │ ├── $resources.sitemap.xml.ts │ │ │ ├── [_route_with_symbols_]._index.server.tsx │ │ │ ├── [_route_with_symbols_]._index.tsx │ │ │ ├── [animations]._index.server.tsx │ │ │ ├── [animations]._index.tsx │ │ │ ├── [assets1]._index.server.tsx │ │ │ ├── [assets1]._index.tsx │ │ │ ├── [class-names]._index.server.tsx │ │ │ ├── [class-names]._index.tsx │ │ │ ├── [content-block]._index.server.tsx │ │ │ ├── [content-block]._index.tsx │ │ │ ├── [duration]._index.server.tsx │ │ │ ├── [duration]._index.tsx │ │ │ ├── [expressions]._index.server.tsx │ │ │ ├── [expressions]._index.tsx │ │ │ ├── [form]._index.server.tsx │ │ │ ├── [form]._index.tsx │ │ │ ├── [head-tag]._index.server.tsx │ │ │ ├── [head-tag]._index.tsx │ │ │ ├── [heading-with-id]._index.server.tsx │ │ │ ├── [heading-with-id]._index.tsx │ │ │ ├── [nested].[nested-page]._index.server.tsx │ │ │ ├── [nested].[nested-page]._index.tsx │ │ │ ├── [radix]._index.server.tsx │ │ │ ├── [radix]._index.tsx │ │ │ ├── [resources]._index.server.tsx │ │ │ ├── [resources]._index.tsx │ │ │ ├── [sitemap.xml]._index.server.tsx │ │ │ ├── [sitemap.xml]._index.tsx │ │ │ ├── [text-duration]._index.server.tsx │ │ │ ├── [text-duration]._index.tsx │ │ │ ├── _index.server.tsx │ │ │ ├── _index.tsx │ │ │ └── index.css │ │ ├── constants.mjs │ │ ├── extension.ts │ │ ├── root.tsx │ │ ├── routes/ │ │ │ ├── [_route_with_symbols_]._index.tsx │ │ │ ├── [animations]._index.tsx │ │ │ ├── [assets1]._index.tsx │ │ │ ├── [class-names]._index.tsx │ │ │ ├── [content-block]._index.tsx │ │ │ ├── [duration]._index.tsx │ │ │ ├── [expressions]._index.tsx │ │ │ ├── [form]._index.tsx │ │ │ ├── [head-tag]._index.tsx │ │ │ ├── [heading-with-id]._index.tsx │ │ │ ├── [nested].[nested-page]._index.tsx │ │ │ ├── [radix]._index.tsx │ │ │ ├── [resources]._index.tsx │ │ │ ├── [robots.txt].tsx │ │ │ ├── [sitemap.xml]._index.tsx │ │ │ ├── [text-duration]._index.tsx │ │ │ └── _index.tsx │ │ └── routes.ts │ ├── package.json │ ├── proxy-emulator/ │ │ └── dedupe-meta.ts │ ├── public/ │ │ └── assets/ │ │ ├── webm-example_2r_6VmRBjhAy3ldaqz0gk.webm │ │ └── webm-example_zNrAFpO1v78xz91jYpaiE.webm │ ├── tsconfig.json │ └── vite.config.ts ├── https/ │ ├── README.md │ ├── fullchain.pem │ ├── haproxy.pem │ ├── haproxy.sh │ └── privkey.pem ├── lostpixel.config.js ├── package.json ├── packages/ │ ├── asset-uploader/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── client.ts │ │ │ ├── clients/ │ │ │ │ ├── fs/ │ │ │ │ │ ├── fs.ts │ │ │ │ │ └── upload.ts │ │ │ │ └── s3/ │ │ │ │ ├── s3.ts │ │ │ │ └── upload.ts │ │ │ ├── constants.ts │ │ │ ├── db/ │ │ │ │ ├── index.ts │ │ │ │ └── load.ts │ │ │ ├── delete.ts │ │ │ ├── index.server.ts │ │ │ ├── index.ts │ │ │ ├── patch.ts │ │ │ ├── schema.ts │ │ │ ├── types.ts │ │ │ ├── upload.ts │ │ │ └── utils/ │ │ │ ├── font-data.test.ts │ │ │ ├── font-data.ts │ │ │ ├── format-asset.test.ts │ │ │ ├── format-asset.ts │ │ │ ├── get-asset-data.ts │ │ │ ├── get-unique-filename.ts │ │ │ ├── sanitize-s3-key.test.ts │ │ │ ├── sanitize-s3-key.ts │ │ │ ├── size-limiter.ts │ │ │ └── to-bytes.ts │ │ ├── tsconfig.json │ │ └── tsconfig.typecheck.json │ ├── authorization-token/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── db/ │ │ │ │ ├── authorization-token.ts │ │ │ │ └── index.ts │ │ │ ├── index.server.ts │ │ │ ├── index.ts │ │ │ └── trpc/ │ │ │ ├── authorization-tokens-router.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── cli/ │ │ ├── .gitignore │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bin.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── args.ts │ │ │ ├── build-utils.ts │ │ │ ├── cli.ts │ │ │ ├── commands/ │ │ │ │ ├── build.ts │ │ │ │ ├── init-flow.ts │ │ │ │ ├── link.ts │ │ │ │ ├── sync.ts │ │ │ │ └── yargs-types.ts │ │ │ ├── config.ts │ │ │ ├── config.ts-expect.ts │ │ │ ├── framework-react-router.ts │ │ │ ├── framework-remix.ts │ │ │ ├── framework-vike-ssg.ts │ │ │ ├── framework.ts │ │ │ ├── fs-utils.ts │ │ │ ├── html-to-jsx.test.ts │ │ │ ├── html-to-jsx.ts │ │ │ └── prebuild.ts │ │ ├── templates/ │ │ │ ├── cloudflare/ │ │ │ │ ├── WS_CF_README.md │ │ │ │ ├── functions/ │ │ │ │ │ └── [[path]].ts │ │ │ │ ├── load-context.ts │ │ │ │ ├── package.json │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vite.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.toml │ │ │ ├── defaults/ │ │ │ │ ├── app/ │ │ │ │ │ ├── constants.mjs │ │ │ │ │ ├── extension.ts │ │ │ │ │ ├── root.tsx │ │ │ │ │ ├── route-templates/ │ │ │ │ │ │ ├── default-sitemap.tsx │ │ │ │ │ │ ├── html.tsx │ │ │ │ │ │ ├── redirect.tsx │ │ │ │ │ │ └── xml.tsx │ │ │ │ │ └── routes/ │ │ │ │ │ └── [robots.txt].tsx │ │ │ │ ├── package.json │ │ │ │ ├── tsconfig.json │ │ │ │ └── vite.config.ts │ │ │ ├── internal/ │ │ │ │ ├── .npmrc │ │ │ │ ├── package.json │ │ │ │ └── tsconfig.json │ │ │ ├── react-router/ │ │ │ │ ├── .gitignore │ │ │ │ ├── app/ │ │ │ │ │ ├── extension.ts │ │ │ │ │ ├── root.tsx │ │ │ │ │ ├── route-templates/ │ │ │ │ │ │ ├── default-sitemap.tsx │ │ │ │ │ │ ├── html.tsx │ │ │ │ │ │ ├── redirect.tsx │ │ │ │ │ │ └── xml.tsx │ │ │ │ │ ├── routes/ │ │ │ │ │ │ └── [robots.txt].tsx │ │ │ │ │ └── routes.ts │ │ │ │ ├── package.json │ │ │ │ ├── tsconfig.json │ │ │ │ └── vite.config.ts │ │ │ ├── react-router-cloudflare/ │ │ │ │ ├── .gitignore │ │ │ │ ├── app/ │ │ │ │ │ ├── constants.mjs │ │ │ │ │ └── entry.server.tsx │ │ │ │ ├── package.json │ │ │ │ ├── react-router.config.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vite.config.ts │ │ │ │ ├── workers/ │ │ │ │ │ └── app.ts │ │ │ │ └── wrangler.jsonc │ │ │ ├── react-router-docker/ │ │ │ │ ├── .dockerignore │ │ │ │ ├── Dockerfile │ │ │ │ ├── app/ │ │ │ │ │ ├── constants.mjs │ │ │ │ │ └── routes/ │ │ │ │ │ └── [_image].$.ts │ │ │ │ └── package.json │ │ │ ├── react-router-netlify/ │ │ │ │ ├── app/ │ │ │ │ │ └── constants.mjs │ │ │ │ ├── netlify.toml │ │ │ │ ├── package.json │ │ │ │ └── vite.config.ts │ │ │ ├── react-router-vercel/ │ │ │ │ ├── app/ │ │ │ │ │ └── constants.mjs │ │ │ │ ├── package.json │ │ │ │ ├── react-router.config.ts │ │ │ │ └── vercel.json │ │ │ ├── saas-helpers/ │ │ │ │ ├── package.json │ │ │ │ ├── tsconfig.json │ │ │ │ └── vite.config.ts │ │ │ ├── ssg/ │ │ │ │ ├── app/ │ │ │ │ │ ├── constants.mjs │ │ │ │ │ └── route-templates/ │ │ │ │ │ └── html/ │ │ │ │ │ ├── +Head.tsx │ │ │ │ │ ├── +Page.tsx │ │ │ │ │ └── +data.ts │ │ │ │ ├── package.json │ │ │ │ ├── pages/ │ │ │ │ │ └── +config.ts │ │ │ │ ├── renderer/ │ │ │ │ │ ├── +onRenderClient.tsx │ │ │ │ │ └── +onRenderHtml.tsx │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vike.d.ts │ │ │ │ └── vite.config.ts │ │ │ ├── ssg-netlify/ │ │ │ │ └── app/ │ │ │ │ └── constants.mjs │ │ │ └── ssg-vercel/ │ │ │ ├── app/ │ │ │ │ └── constants.mjs │ │ │ └── public/ │ │ │ └── vercel.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── css-data/ │ │ ├── LICENSE │ │ ├── LICENSE-3RD-PARTY │ │ ├── README.md │ │ ├── bin/ │ │ │ ├── css-to-ws.ts │ │ │ ├── css-tree-dist-data.d.ts │ │ │ ├── html.css.ts │ │ │ ├── mdn-data.ts │ │ │ ├── prompts/ │ │ │ │ ├── declarations.prompt.md │ │ │ │ ├── properties.prompt.md │ │ │ │ └── pseudo-selectors.prompt.md │ │ │ └── property-value-descriptions.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ ├── animatable-properties.ts │ │ │ │ ├── html.ts │ │ │ │ ├── keyword-values.ts │ │ │ │ ├── properties.ts │ │ │ │ ├── property-value-descriptions.ts │ │ │ │ ├── pseudo-classes.ts │ │ │ │ ├── pseudo-elements.ts │ │ │ │ ├── pseudo-selector-descriptions.ts │ │ │ │ ├── shorthand-properties.ts │ │ │ │ └── units.ts │ │ │ ├── custom-data.ts │ │ │ ├── html.css │ │ │ ├── index.ts │ │ │ ├── media-condition-simulator.test.ts │ │ │ ├── media-condition-simulator.ts │ │ │ ├── parse-css-value.test.ts │ │ │ ├── parse-css-value.ts │ │ │ ├── parse-css.test.ts │ │ │ ├── parse-css.ts │ │ │ ├── property-parsers/ │ │ │ │ ├── conic-gradient.test.ts │ │ │ │ ├── conic-gradient.ts │ │ │ │ ├── gradient-utils.ts │ │ │ │ ├── grid-template-areas.test.ts │ │ │ │ ├── grid-template-areas.ts │ │ │ │ ├── grid-template-tracks.test.ts │ │ │ │ ├── grid-template-tracks.ts │ │ │ │ ├── index.ts │ │ │ │ ├── linear-gradient.test.ts │ │ │ │ ├── linear-gradient.ts │ │ │ │ ├── radial-gradient.test.ts │ │ │ │ ├── radial-gradient.ts │ │ │ │ └── types.ts │ │ │ ├── selector-validation.test.ts │ │ │ ├── selector-validation.ts │ │ │ ├── shorthands.test.ts │ │ │ └── shorthands.ts │ │ └── tsconfig.json │ ├── css-engine/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ └── types.ts │ │ │ ├── core/ │ │ │ │ ├── atomic.test.ts │ │ │ │ ├── atomic.ts │ │ │ │ ├── compare-media.test.ts │ │ │ │ ├── compare-media.ts │ │ │ │ ├── create-style-sheet.ts │ │ │ │ ├── css-engine.stories.tsx │ │ │ │ ├── equal-media.test.ts │ │ │ │ ├── equal-media.ts │ │ │ │ ├── find-applicable-media.test.ts │ │ │ │ ├── find-applicable-media.ts │ │ │ │ ├── index.ts │ │ │ │ ├── match-media.test.ts │ │ │ │ ├── match-media.ts │ │ │ │ ├── merger.test.ts │ │ │ │ ├── merger.ts │ │ │ │ ├── prefixer.test.ts │ │ │ │ ├── prefixer.ts │ │ │ │ ├── rules.test.ts │ │ │ │ ├── rules.ts │ │ │ │ ├── style-element.ts │ │ │ │ ├── style-sheet-regular.test.ts │ │ │ │ ├── style-sheet-regular.ts │ │ │ │ ├── style-sheet.ts │ │ │ │ ├── to-property.test.ts │ │ │ │ ├── to-property.ts │ │ │ │ ├── to-value.test.ts │ │ │ │ └── to-value.ts │ │ │ ├── css.ts │ │ │ ├── index.ts │ │ │ ├── runtime.ts │ │ │ └── schema.ts │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── dashboard/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── db/ │ │ │ │ ├── index.ts │ │ │ │ └── projects.ts │ │ │ ├── index.server.ts │ │ │ ├── index.ts │ │ │ └── trpc/ │ │ │ ├── index.ts │ │ │ └── project-router.ts │ │ └── tsconfig.json │ ├── design-system/ │ │ ├── LICENSE │ │ ├── bin/ │ │ │ └── transform-figma-tokens.ts │ │ ├── documentation/ │ │ │ └── figma-design-tokens.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ ├── figma-design-tokens.json │ │ │ │ └── figma-design-tokens.ts │ │ │ ├── components/ │ │ │ │ ├── __DEPRECATED__/ │ │ │ │ │ ├── list.stories.tsx │ │ │ │ │ └── list.tsx │ │ │ │ ├── avatar.stories.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── box.tsx │ │ │ │ ├── button.stories.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.stories.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── checkbox.stories.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── color-picker.stories.tsx │ │ │ │ ├── color-picker.tsx │ │ │ │ ├── combobox.stories.tsx │ │ │ │ ├── combobox.tsx │ │ │ │ ├── command.stories.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── component-card.stories.tsx │ │ │ │ ├── component-card.tsx │ │ │ │ ├── context-menu.stories.tsx │ │ │ │ ├── context-menu.tsx │ │ │ │ ├── css-value-list-item.stories.tsx │ │ │ │ ├── css-value-list-item.tsx │ │ │ │ ├── dialog.stories.tsx │ │ │ │ ├── dialog.test.ts │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.stories.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── enhanced-tooltip.stories.tsx │ │ │ │ ├── enhanced-tooltip.tsx │ │ │ │ ├── flex.tsx │ │ │ │ ├── floating-panel.stories.tsx │ │ │ │ ├── floating-panel.tsx │ │ │ │ ├── focus-ring.ts │ │ │ │ ├── gradient-picker.stories.tsx │ │ │ │ ├── gradient-picker.tsx │ │ │ │ ├── grid.tsx │ │ │ │ ├── icon-button.stories.tsx │ │ │ │ ├── icon-button.tsx │ │ │ │ ├── input-field.stories.tsx │ │ │ │ ├── input-field.tsx │ │ │ │ ├── kbd.stories.tsx │ │ │ │ ├── kbd.tsx │ │ │ │ ├── label.stories.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── link.stories.tsx │ │ │ │ ├── link.tsx │ │ │ │ ├── list-position-indicator.stories.tsx │ │ │ │ ├── list-position-indicator.tsx │ │ │ │ ├── menu.stories.tsx │ │ │ │ ├── menu.tsx │ │ │ │ ├── nested-icon-label.stories.tsx │ │ │ │ ├── nested-icon-label.tsx │ │ │ │ ├── nested-input-button.stories.tsx │ │ │ │ ├── nested-input-button.tsx │ │ │ │ ├── panel-banner.stories.tsx │ │ │ │ ├── panel-banner.tsx │ │ │ │ ├── panel-tabs.stories.tsx │ │ │ │ ├── panel-tabs.tsx │ │ │ │ ├── panel-title.stories.tsx │ │ │ │ ├── panel-title.tsx │ │ │ │ ├── popover.stories.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── position-grid.stories.tsx │ │ │ │ ├── position-grid.tsx │ │ │ │ ├── primitives/ │ │ │ │ │ ├── arrow-focus.tsx │ │ │ │ │ ├── create-content-controller.stories.tsx │ │ │ │ │ ├── create-content-controller.ts │ │ │ │ │ ├── dnd/ │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ └── geometry-utils.test.ts.snap │ │ │ │ │ │ ├── canvas.stories.tsx │ │ │ │ │ │ ├── dom-utils.ts │ │ │ │ │ │ ├── geometry-utils.test.ts │ │ │ │ │ │ ├── geometry-utils.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── placement-indicator.tsx │ │ │ │ │ │ ├── sortable-list.stories.tsx │ │ │ │ │ │ ├── use-auto-scroll.test.ts │ │ │ │ │ │ ├── use-auto-scroll.ts │ │ │ │ │ │ ├── use-drag-cursor.ts │ │ │ │ │ │ ├── use-drag.ts │ │ │ │ │ │ ├── use-drop.ts │ │ │ │ │ │ ├── use-hold.ts │ │ │ │ │ │ └── use-sortable.tsx │ │ │ │ │ ├── is-truncated.tsx │ │ │ │ │ ├── list.tsx │ │ │ │ │ ├── numeric-gesture-control.stories.tsx │ │ │ │ │ ├── numeric-gesture-control.ts │ │ │ │ │ ├── numeric-input-arrow-keys.ts │ │ │ │ │ ├── small-button.stories.tsx │ │ │ │ │ ├── small-button.tsx │ │ │ │ │ └── use-scrub.ts │ │ │ │ ├── pro-badge.stories.tsx │ │ │ │ ├── pro-badge.tsx │ │ │ │ ├── progress.stories.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── radio.stories.tsx │ │ │ │ ├── radio.tsx │ │ │ │ ├── scroll-area.stories.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── search-field.stories.tsx │ │ │ │ ├── search-field.tsx │ │ │ │ ├── section-title.stories.tsx │ │ │ │ ├── section-title.tsx │ │ │ │ ├── select-button.stories.tsx │ │ │ │ ├── select-button.tsx │ │ │ │ ├── select.stories.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.stories.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── small-icon-button.stories.tsx │ │ │ │ ├── small-icon-button.tsx │ │ │ │ ├── small-toggle-button.stories.tsx │ │ │ │ ├── small-toggle-button.tsx │ │ │ │ ├── storybook.tsx │ │ │ │ ├── switch.stories.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── text-area.stories.tsx │ │ │ │ ├── text-area.tsx │ │ │ │ ├── text.stories.tsx │ │ │ │ ├── text.ts │ │ │ │ ├── toast.stories.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── toggle-button.stories.tsx │ │ │ │ ├── toggle-button.tsx │ │ │ │ ├── toggle-group.stories.tsx │ │ │ │ ├── toggle-group.tsx │ │ │ │ ├── toolbar.stories.tsx │ │ │ │ ├── toolbar.tsx │ │ │ │ ├── tooltip.stories.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ ├── tree.stories.tsx │ │ │ │ ├── tree.tsx │ │ │ │ ├── two-rows-icon-button-container.stories.tsx │ │ │ │ └── two-rows-icon-button-container.tsx │ │ │ ├── index.ts │ │ │ ├── stitches.config.ts │ │ │ └── utilities.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.typecheck.json │ │ └── vitest.config.ts │ ├── domain/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── db/ │ │ │ │ ├── cname-from-user-id.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── index.ts │ │ │ │ └── validate.ts │ │ │ ├── index.server.ts │ │ │ ├── index.ts │ │ │ ├── rdap.ts │ │ │ └── trpc/ │ │ │ ├── domain.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── feature-flags/ │ │ ├── LICENSE │ │ ├── package.json │ │ ├── src/ │ │ │ ├── feature.ts │ │ │ ├── flags.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── fonts/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── constants.ts │ │ │ ├── font-weights.ts │ │ │ ├── get-font-faces.test.ts │ │ │ ├── get-font-faces.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── generate-arg-types/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── arg-types.ts │ │ │ ├── cli.ts │ │ │ └── props/ │ │ │ └── add-descriptions.ts │ │ └── tsconfig.json │ ├── html-data/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bin/ │ │ │ ├── aria.ts │ │ │ ├── attributes.ts │ │ │ ├── crawler.ts │ │ │ ├── elements.ts │ │ │ ├── overrides.ts │ │ │ └── possible-standard-names.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ ├── aria-jsx-test.tsx │ │ │ │ ├── aria.ts │ │ │ │ ├── attributes-jsx-test.tsx │ │ │ │ ├── attributes.ts │ │ │ │ └── elements.ts │ │ │ ├── index.ts │ │ │ └── pseudo-classes.ts │ │ ├── tsconfig.json │ │ └── tsconfig.typecheck.json │ ├── http-client/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── icons/ │ │ ├── LICENSE │ │ ├── generate.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ ├── components.tsx │ │ │ │ └── svg.ts │ │ │ ├── index.stories.tsx │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── svg-string.ts │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── image/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── image-dev.stories.tsx │ │ │ ├── image-loader.test.ts │ │ │ ├── image-loaders.ts │ │ │ ├── image-optimize.test.ts │ │ │ ├── image-optimize.ts │ │ │ ├── image.tsx │ │ │ └── index.ts │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── postgrest/ │ │ ├── README.md │ │ ├── package.json │ │ ├── playground/ │ │ │ ├── domains.ts │ │ │ └── pnpm-playground │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ └── db-types.ts │ │ │ └── index.server.ts │ │ ├── supabase/ │ │ │ ├── SQL-TESTS-AI.md │ │ │ └── tests/ │ │ │ ├── cleanup-builds.sql │ │ │ ├── latest-builds-domains.sql │ │ │ ├── latest-builds-projects.sql │ │ │ └── project-domains.sql │ │ └── tsconfig.json │ ├── prisma-client/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── migrations-cli/ │ │ │ ├── README.md │ │ │ ├── TROUBLESHOOTING.md │ │ │ ├── args.ts │ │ │ ├── cli.ts │ │ │ ├── commands.ts │ │ │ ├── errors.ts │ │ │ ├── logger.ts │ │ │ ├── prisma-migrations.ts │ │ │ └── umzug.ts │ │ ├── package.json │ │ ├── prisma/ │ │ │ ├── migrations/ │ │ │ │ ├── 20220601192603_start/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220608130924_/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220608130959_adduser/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220608131719_add_user/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220611090740_/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220611091346_add_email/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220616143541_add_projects/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220616143902_userid_not_mandatory/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220619163536_userid_mandatory/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220624214305_teams/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220624215036_remove_userid/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220624235138_users_have_projects/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220714112221_add_assets/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220714114102_remove_size/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220715192633_add_alt/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220716191150_add_more_info_to_asset/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220716192051_make_metadata_not_mandatory/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220717152939_make_width_and_height_float/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220717193140_make_width_and_height_decimal/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220722131820_remove_path/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220722132445_add_location/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220905153337_noop/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20220909124449_builds/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220909124542_builds-data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20220909131750_builds-cleanup/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220912141854_assets-meta/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220912142938_assets-meta-data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20220912150542_assets-meta-cleanup/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220915143947_breakpoints-build/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20220915144008_breakpoints-build_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20220915145316_breakpoints-build_cleanup/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20221021172622_asset_format_notnull/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20221021172647_lowercase_domains/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20221126165439_design-tokens/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20221201075120_user-props/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20221208123312_remove-assets-from-project/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20221218211129_tree_text/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20221227220622_assets-status/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20221230120125_tree_preset_styles/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230106000103_tree_styles/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230106000143_tree_styles_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230115165217_tree_instances/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230115165314_tree_instances_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230119013820_instance-props-uniq/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230119181836_tree_props/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230119181858_tree_props_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230120130130_dashboard-project/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230120130131_dashboard-project-is-prod/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230123225816_dashboard-project-is-deleted/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230124131218_authorization-tokens/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230127120101_style_sources/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230129141714_unused_schema/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230129174218_fill-auth-view-tokens/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230130121014_tree_relations/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230130121041_tree_relations_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230130124937_tree_relations_not_null/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230130153140_authorization-tokens-fix/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230130160827_build_styles/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230202131409_project-created-at/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230202174408_build_breakpoints/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230202174456_build_breakpoints_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230202192437_composite_ids/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230213220858_is-deleted-uniq/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230227142607_build_style_source_selections/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230227142622_build_style_source_selections_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230227180214_build_props/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230227180250_build_props_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230228132402_drop_style_source_tree_id/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230228161419_page_root_instance_id/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230228194425_build_instances/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230228194553_build_instances_data/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230228222541_convert-image-style/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230301101527_drop_page_tree_id/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230301101856_drop_tree/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230301134408_convert-background-to-layers/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230309142820_link-target/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230412160008_min-width-remove/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230501151815_file/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230501151941_asset-to-file/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230501153024_asset-file-relation/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230515112405_file_uploader_project/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230517133730_file_is_deleted/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230517150043_domain/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230520112258_drop_breakpoints/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230529133454_build_version/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230530132921_deployment/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230530155049_drop_unused_asset_fields/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230605174851_domain-updated-at/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230606165920_deployment-project-domain/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230606234538_latest-build/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230610111903_button_children/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230611121710_merge_block_text_components/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230611181439_control_labels/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230619131628_build_data_sources/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230702002752_form_data_sources/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20230831150459_add-administrators/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20230911125308_form_action/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20231105075338_add-last-transaction-id/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231108172804_prop_expression/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20231115205820_client-references/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231116173417_product/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231117095612_transaction/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231119153806_transaction-customer-subscription/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231120172840_add-event-type/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231121125755_token-uniq/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231129164239_event-data/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20231211152313_build_resources/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240112011509_folders/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20240112155724_nullable-user/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240112155725_user_product_view/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240125182656_calc-domains/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240127230238_project-preview/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240131193159_new_constraints/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240131200102_page_meta_expressions/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20240227150630_marketplace/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240229133316_add-approval-status/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240308131249_add-token-rights/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240309212641_page_system_variable/ │ │ │ │ │ ├── migration.ts │ │ │ │ │ └── schema.prisma │ │ │ │ ├── 20240315173349_add-approved-marketplace-product/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240530162819_marketplace-token/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240723144019_latest-build/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240723150501_latest-static-build/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240725003228_clone_project/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240730131207_clone_project_preview_imagea/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240731170412_create_production_build/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240807000548_deleted-project-free-domain/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240809220753_tz/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240916143551_time/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240917152817_pgtap/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240918100751_latest-virtual-build/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240920091253_domain-ordering/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20240924174536_cleanup/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20241207052014_can-publish/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250322141808_user_publish_count/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250710163439_restore_development_build/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250810123401_asset_filename_description/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250913204036_project_tags/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20251129093846_add_updated_at_to_latest_build_virtual/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── migration_lock.toml │ │ │ │ └── template.txt │ │ │ └── schema.prisma │ │ ├── prisma.cjs │ │ ├── prisma.mjs │ │ ├── src/ │ │ │ ├── cjs/ │ │ │ │ └── package.json │ │ │ └── prisma.ts │ │ └── tsconfig.json │ ├── project/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── db/ │ │ │ │ ├── project-domain.ts │ │ │ │ └── project.ts │ │ │ ├── index.server.ts │ │ │ ├── index.ts │ │ │ ├── shared/ │ │ │ │ └── schema.ts │ │ │ └── trpc/ │ │ │ └── project-router.ts │ │ └── tsconfig.json │ ├── project-build/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── db/ │ │ │ │ ├── build.ts │ │ │ │ ├── deployment.ts │ │ │ │ ├── pages.ts │ │ │ │ ├── style-source-selections.ts │ │ │ │ └── styles.ts │ │ │ ├── index.server.ts │ │ │ ├── index.ts │ │ │ ├── shared/ │ │ │ │ ├── graph-utils.test.ts │ │ │ │ ├── graph-utils.ts │ │ │ │ ├── marketplace.ts │ │ │ │ ├── pages-utils.test.ts │ │ │ │ └── pages-utils.ts │ │ │ ├── template.tsx │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── react-sdk/ │ │ ├── LICENSE │ │ ├── LICENSE-3RD-PARTY │ │ ├── README.md │ │ ├── package.json │ │ ├── placeholder.d.ts │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ └── standard-attributes.ts │ │ │ ├── collection-utils.test.ts │ │ │ ├── collection-utils.ts │ │ │ ├── component-generator.test.tsx │ │ │ ├── component-generator.ts │ │ │ ├── components/ │ │ │ │ └── components-utils.ts │ │ │ ├── context.tsx │ │ │ ├── hook.test.ts │ │ │ ├── hook.ts │ │ │ ├── index.ts │ │ │ ├── page-settings-canonical-link.tsx │ │ │ ├── page-settings-meta.tsx │ │ │ ├── page-settings-title.tsx │ │ │ ├── props.test.ts │ │ │ ├── props.ts │ │ │ ├── remix.test.ts │ │ │ ├── remix.ts │ │ │ ├── runtime.ts │ │ │ └── variable-state.tsx │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── sdk/ │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── normalize.css.ts │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ ├── normalize.css.ts │ │ │ │ └── tags.ts │ │ │ ├── assets.test.ts │ │ │ ├── assets.ts │ │ │ ├── core-metas.ts │ │ │ ├── core-templates.tsx │ │ │ ├── css.test.tsx │ │ │ ├── css.ts │ │ │ ├── expression.test.ts │ │ │ ├── expression.ts │ │ │ ├── form-fields.ts │ │ │ ├── index.ts │ │ │ ├── instances-utils.test.tsx │ │ │ ├── instances-utils.ts │ │ │ ├── normalize.css │ │ │ ├── page-meta-generator.test.ts │ │ │ ├── page-meta-generator.ts │ │ │ ├── page-utils.test.ts │ │ │ ├── page-utils.ts │ │ │ ├── resource-loader.test.ts │ │ │ ├── resource-loader.ts │ │ │ ├── resources-generator.test.tsx │ │ │ ├── resources-generator.ts │ │ │ ├── router-path-test-data.ts │ │ │ ├── router-paths.test.ts │ │ │ ├── runtime.ts │ │ │ ├── schema/ │ │ │ │ ├── animation-schema.ts │ │ │ │ ├── assets.ts │ │ │ │ ├── breakpoints.test.ts │ │ │ │ ├── breakpoints.ts │ │ │ │ ├── component-meta.ts │ │ │ │ ├── data-sources.ts │ │ │ │ ├── deployment.ts │ │ │ │ ├── instances.ts │ │ │ │ ├── pages.test.ts │ │ │ │ ├── pages.ts │ │ │ │ ├── prop-meta.ts │ │ │ │ ├── props.ts │ │ │ │ ├── resources.ts │ │ │ │ ├── style-source-selections.ts │ │ │ │ ├── style-sources.ts │ │ │ │ ├── styles.ts │ │ │ │ └── webstudio.ts │ │ │ ├── scope.test.ts │ │ │ ├── scope.ts │ │ │ ├── to-string.ts │ │ │ ├── url-pattern.test.ts │ │ │ └── url-pattern.ts │ │ ├── tsconfig.dts.json │ │ ├── tsconfig.json │ │ └── tsconfig.typecheck.json │ ├── sdk-cli/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── bin.ts │ │ │ ├── cli.ts │ │ │ └── generate-stories.ts │ │ └── tsconfig.json │ ├── sdk-components-animation/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ ├── animate-children.props.ts │ │ │ │ ├── animate-text.props.ts │ │ │ │ ├── stagger-animation.props.ts │ │ │ │ └── video-animation.props.ts │ │ │ ├── animate-children.tsx │ │ │ ├── animate-children.ws.ts │ │ │ ├── animate-text.tsx │ │ │ ├── animate-text.ws.ts │ │ │ ├── components.ts │ │ │ ├── hooks.ts │ │ │ ├── metas.ts │ │ │ ├── shared/ │ │ │ │ ├── create-progress-animation.tsx │ │ │ │ ├── meta.ts │ │ │ │ └── proxy.ts │ │ │ ├── stagger-animation.tsx │ │ │ ├── stagger-animation.ws.ts │ │ │ ├── templates.ts │ │ │ ├── video-animation.template.tsx │ │ │ ├── video-animation.tsx │ │ │ └── video-animation.ws.ts │ │ ├── tsconfig.dts.json │ │ ├── tsconfig.json │ │ ├── tsconfig.typecheck.json │ │ ├── vite.config.ts │ │ └── vitest.config.ts │ ├── sdk-components-react/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── LICENSE │ │ │ ├── __generated__/ │ │ │ │ ├── blockquote.props.ts │ │ │ │ ├── blockquote.stories.tsx │ │ │ │ ├── body.props.ts │ │ │ │ ├── bold.props.ts │ │ │ │ ├── box.props.ts │ │ │ │ ├── button.props.ts │ │ │ │ ├── button.stories.tsx │ │ │ │ ├── checkbox.props.ts │ │ │ │ ├── checkbox.stories.tsx │ │ │ │ ├── code-text.props.ts │ │ │ │ ├── content-embed.stories.tsx │ │ │ │ ├── form.props.ts │ │ │ │ ├── form.stories.tsx │ │ │ │ ├── fragment.props.ts │ │ │ │ ├── head-link.props.ts │ │ │ │ ├── head-meta.props.ts │ │ │ │ ├── head-slot.props.ts │ │ │ │ ├── head-title.props.ts │ │ │ │ ├── heading.props.ts │ │ │ │ ├── heading.stories.tsx │ │ │ │ ├── html-embed.props.ts │ │ │ │ ├── image.props.ts │ │ │ │ ├── input.props.ts │ │ │ │ ├── italic.props.ts │ │ │ │ ├── label.props.ts │ │ │ │ ├── label.stories.tsx │ │ │ │ ├── link.props.ts │ │ │ │ ├── link.stories.tsx │ │ │ │ ├── list-item.props.ts │ │ │ │ ├── list-item.stories.tsx │ │ │ │ ├── list.props.ts │ │ │ │ ├── list.stories.tsx │ │ │ │ ├── markdown-embed.props.ts │ │ │ │ ├── markdown-embed.stories.tsx │ │ │ │ ├── option.props.ts │ │ │ │ ├── paragraph.props.ts │ │ │ │ ├── paragraph.stories.tsx │ │ │ │ ├── radio-button.props.ts │ │ │ │ ├── radio-button.stories.tsx │ │ │ │ ├── rich-text-link.props.ts │ │ │ │ ├── select.props.ts │ │ │ │ ├── select.stories.tsx │ │ │ │ ├── separator.props.ts │ │ │ │ ├── slot.props.ts │ │ │ │ ├── span.props.ts │ │ │ │ ├── subscript.props.ts │ │ │ │ ├── superscript.props.ts │ │ │ │ ├── text.props.ts │ │ │ │ ├── text.stories.tsx │ │ │ │ ├── textarea.props.ts │ │ │ │ ├── time.props.ts │ │ │ │ ├── video.props.ts │ │ │ │ ├── vimeo-play-button.props.ts │ │ │ │ ├── vimeo-preview-image.props.ts │ │ │ │ ├── vimeo-spinner.props.ts │ │ │ │ ├── vimeo.props.ts │ │ │ │ ├── vimeo.stories.tsx │ │ │ │ ├── webhook-form.props.ts │ │ │ │ ├── xml-node.props.ts │ │ │ │ ├── xml-time.props.ts │ │ │ │ ├── you-tube.stories.tsx │ │ │ │ └── youtube.props.ts │ │ │ ├── blockquote.tsx │ │ │ ├── blockquote.ws.ts │ │ │ ├── body.tsx │ │ │ ├── body.ws.ts │ │ │ ├── bold.tsx │ │ │ ├── bold.ws.ts │ │ │ ├── box.tsx │ │ │ ├── box.ws.ts │ │ │ ├── button.tsx │ │ │ ├── button.ws.ts │ │ │ ├── checkbox.tsx │ │ │ ├── checkbox.ws.ts │ │ │ ├── code-text.tsx │ │ │ ├── code-text.ws.ts │ │ │ ├── components.ts │ │ │ ├── content-embed.template.tsx │ │ │ ├── form.tsx │ │ │ ├── form.ws.ts │ │ │ ├── fragment.tsx │ │ │ ├── fragment.ws.ts │ │ │ ├── head-link.tsx │ │ │ ├── head-link.ws.ts │ │ │ ├── head-meta.tsx │ │ │ ├── head-meta.ws.ts │ │ │ ├── head-slot.template.tsx │ │ │ ├── head-slot.tsx │ │ │ ├── head-slot.ws.ts │ │ │ ├── head-title.tsx │ │ │ ├── head-title.ws.ts │ │ │ ├── heading.tsx │ │ │ ├── heading.ws.ts │ │ │ ├── hooks.ts │ │ │ ├── html-embed-patchers.ts │ │ │ ├── html-embed.test.tsx │ │ │ ├── html-embed.tsx │ │ │ ├── html-embed.ws.ts │ │ │ ├── image.tsx │ │ │ ├── image.ws.ts │ │ │ ├── input.tsx │ │ │ ├── input.ws.ts │ │ │ ├── italic.tsx │ │ │ ├── italic.ws.ts │ │ │ ├── label.tsx │ │ │ ├── label.ws.ts │ │ │ ├── link.tsx │ │ │ ├── link.ws.ts │ │ │ ├── list-item.tsx │ │ │ ├── list-item.ws.ts │ │ │ ├── list.tsx │ │ │ ├── list.ws.ts │ │ │ ├── markdown-embed.template.tsx │ │ │ ├── markdown-embed.tsx │ │ │ ├── markdown-embed.ws.ts │ │ │ ├── metas.ts │ │ │ ├── option.tsx │ │ │ ├── option.ws.ts │ │ │ ├── paragraph.tsx │ │ │ ├── paragraph.ws.ts │ │ │ ├── radio-button.tsx │ │ │ ├── radio-button.ws.ts │ │ │ ├── rich-text-link.tsx │ │ │ ├── rich-text-link.ws.ts │ │ │ ├── select.tsx │ │ │ ├── select.ws.ts │ │ │ ├── separator.tsx │ │ │ ├── separator.ws.ts │ │ │ ├── shared/ │ │ │ │ └── video.ts │ │ │ ├── slot.tsx │ │ │ ├── slot.ws.ts │ │ │ ├── span.tsx │ │ │ ├── span.ws.ts │ │ │ ├── subscript.tsx │ │ │ ├── subscript.ws.ts │ │ │ ├── superscript.tsx │ │ │ ├── superscript.ws.ts │ │ │ ├── templates.ts │ │ │ ├── test-utils/ │ │ │ │ └── cartesian.ts │ │ │ ├── text.tsx │ │ │ ├── text.ws.ts │ │ │ ├── textarea.tsx │ │ │ ├── textarea.ws.ts │ │ │ ├── time.test.ts │ │ │ ├── time.tsx │ │ │ ├── time.ws.ts │ │ │ ├── video.tsx │ │ │ ├── video.ws.ts │ │ │ ├── vimeo-play-button.tsx │ │ │ ├── vimeo-play-button.ws.ts │ │ │ ├── vimeo-preview-image.tsx │ │ │ ├── vimeo-preview-image.ws.ts │ │ │ ├── vimeo-spinner.tsx │ │ │ ├── vimeo-spinner.ws.ts │ │ │ ├── vimeo.template.tsx │ │ │ ├── vimeo.tsx │ │ │ ├── vimeo.ws.ts │ │ │ ├── webhook-form.template.tsx │ │ │ ├── webhook-form.tsx │ │ │ ├── webhook-form.ws.ts │ │ │ ├── xml-node.stories.tsx │ │ │ ├── xml-node.tsx │ │ │ ├── xml-node.ws.ts │ │ │ ├── xml-time.tsx │ │ │ ├── xml-time.ws.ts │ │ │ ├── youtube.template.tsx │ │ │ ├── youtube.tsx │ │ │ └── youtube.ws.ts │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── sdk-components-react-radix/ │ │ ├── LICENSE │ │ ├── LICENSE-3RD-PARTY │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __generated__/ │ │ │ │ ├── accordion.props.ts │ │ │ │ ├── accordion.stories.tsx │ │ │ │ ├── checkbox.props.ts │ │ │ │ ├── checkbox.stories.tsx │ │ │ │ ├── collapsible.props.ts │ │ │ │ ├── collapsible.stories.tsx │ │ │ │ ├── dialog.props.ts │ │ │ │ ├── dialog.stories.tsx │ │ │ │ ├── label.props.ts │ │ │ │ ├── label.stories.tsx │ │ │ │ ├── navigation-menu.props.ts │ │ │ │ ├── navigation-menu.stories.tsx │ │ │ │ ├── popover.props.ts │ │ │ │ ├── popover.stories.tsx │ │ │ │ ├── radio-group.props.ts │ │ │ │ ├── radio-group.stories.tsx │ │ │ │ ├── select.props.ts │ │ │ │ ├── select.stories.tsx │ │ │ │ ├── sheet.stories.tsx │ │ │ │ ├── switch.props.ts │ │ │ │ ├── switch.stories.tsx │ │ │ │ ├── tabs.props.ts │ │ │ │ ├── tabs.stories.tsx │ │ │ │ ├── tooltip.props.ts │ │ │ │ └── tooltip.stories.tsx │ │ │ ├── accordion.template.tsx │ │ │ ├── accordion.tsx │ │ │ ├── accordion.ws.ts │ │ │ ├── checkbox.template.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── checkbox.ws.ts │ │ │ ├── collapsible.template.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── collapsible.ws.ts │ │ │ ├── components.ts │ │ │ ├── dialog.template.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dialog.ws.ts │ │ │ ├── hooks.ts │ │ │ ├── label.template.tsx │ │ │ ├── label.tsx │ │ │ ├── label.ws.ts │ │ │ ├── metas.ts │ │ │ ├── navigation-menu.template.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── navigation-menu.ws.ts │ │ │ ├── popover.template.tsx │ │ │ ├── popover.tsx │ │ │ ├── popover.ws.ts │ │ │ ├── props-descriptions.ts │ │ │ ├── radio-group.template.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── radio-group.ws.ts │ │ │ ├── select.template.tsx │ │ │ ├── select.tsx │ │ │ ├── select.ws.ts │ │ │ ├── shared/ │ │ │ │ ├── meta.ts │ │ │ │ ├── preset-styles.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── styles.ts │ │ │ │ └── theme.ts │ │ │ ├── sheet.template.tsx │ │ │ ├── switch.template.tsx │ │ │ ├── switch.tsx │ │ │ ├── switch.ws.ts │ │ │ ├── tabs.template.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs.ws.ts │ │ │ ├── templates.ts │ │ │ ├── tooltip.template.tsx │ │ │ ├── tooltip.tsx │ │ │ └── tooltip.ws.ts │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── sdk-components-react-remix/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── body.tsx │ │ │ ├── components.ts │ │ │ ├── link.tsx │ │ │ ├── remix-form.tsx │ │ │ ├── rich-text-link.tsx │ │ │ └── webhook-form.tsx │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── sdk-components-react-router/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── body.tsx │ │ │ ├── components.ts │ │ │ ├── link.tsx │ │ │ ├── metas.ts │ │ │ ├── remix-form.tsx │ │ │ ├── rich-text-link.tsx │ │ │ └── webhook-form.tsx │ │ ├── tsconfig.dts.json │ │ └── tsconfig.json │ ├── template/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── css.test.ts │ │ │ ├── css.ts │ │ │ ├── index.ts │ │ │ ├── jsx.test.tsx │ │ │ ├── jsx.ts │ │ │ └── template.ts │ │ └── tsconfig.json │ ├── trpc-interface/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── authorize/ │ │ │ │ └── project.server.ts │ │ │ ├── context/ │ │ │ │ ├── context.server.ts │ │ │ │ ├── errors.server.ts │ │ │ │ └── router.server.ts │ │ │ ├── index.server.ts │ │ │ ├── index.ts │ │ │ ├── shared/ │ │ │ │ ├── client.ts │ │ │ │ ├── deployment.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── shared-router.ts │ │ │ │ └── trpc.ts │ │ │ ├── trpc-caller-link.test.ts │ │ │ └── trpc-caller-link.ts │ │ └── tsconfig.json │ └── tsconfig/ │ ├── README.md │ ├── base.json │ └── package.json ├── patches/ │ ├── @radix-ui__react-scroll-area@1.0.5.patch │ ├── @remix-run__dev.patch │ └── @stitches__react@1.3.1-1.patch ├── pnpm-workspace.yaml ├── release.sh ├── submodules.sh ├── vercel.json ├── vite.sdk-components.config.ts └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/.gitignore ================================================ .local ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm ENV PATH=/usr/local/bin:${PATH} # Install latest pnpm # RUN npm install -g pnpm@9.0.2 # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends # [Optional] Uncomment if you want to install an additional version of node using nvm # ARG EXTRA_NODE_VERSION=10 # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" # [Optional] Uncomment if you want to install more global node modules # RUN su node -c "npm install -g " COPY library-scripts/*.sh /tmp/library-scripts/ ENV DOCKER_BUILDKIT=1 RUN apt-get update RUN /bin/bash /tmp/library-scripts/docker-in-docker-debian.sh ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres { "name": "Node.js & PostgreSQL", "dockerComposeFile": "docker-compose.yml", "service": "app", "workspaceFolder": "/workspaces/webstudio", "features": { "ghcr.io/robbert229/devcontainer-features/postgresql-client:1": { "version": "15" } }, // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. // This can be used to network with other containers or with the host. // "forwardPorts": [3000, 5432], "forwardPorts": [5173], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": ".devcontainer/postinstall.sh", // "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "customizations": { "vscode": { "extensions": [ "esbenp.prettier-vscode", "redhat.vscode-yaml", "me-dutour-mathieu.vscode-github-actions", "eamodio.gitlens", "bradymholt.pgformatter", "YoavBls.pretty-ts-errors", "typescriptteam.native-preview" ] } } // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" } ================================================ FILE: .devcontainer/docker-compose.yml ================================================ version: "3.8" services: app: init: true privileged: true build: context: . dockerfile: Dockerfile volumes: - ..:/workspaces/webstudio:cached # preserve history - ./.local:/home/node/.local - docker-data:/var/lib/docker - ${HOME}/.github/instructions:/home/node/.github/instructions entrypoint: ["/usr/local/share/docker-init.sh"] # Overrides default command so things don't shut down after the process ends. command: sleep infinity # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. network_mode: service:db depends_on: db: condition: service_healthy db: image: ghcr.io/supabase/postgres:15.1.1.55 # Uncomment to log all queries command: ["postgres", "-c", "log_statement=all", "-c", "listen_addresses=*"] restart: unless-stopped volumes: - postgres-data:/var/lib/postgresql/data environment: POSTGRES_PASSWORD: pass POSTGRES_DB: webstudio ports: - ${PGPORT:-5432}:5432 - 3000:3000 healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d webstudio"] interval: 10s timeout: 5s retries: 25 rest: container_name: supabase-rest image: postgrest/postgrest:v12.2.0 depends_on: db: # Disable this if you are using an external Postgres database condition: service_healthy restart: unless-stopped environment: PGRST_DB_URI: postgresql://postgres:pass@localhost/webstudio PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS:-public} PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: ${JWT_SECRET:-jwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecret} PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET:-jwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecretjwtsecret} PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} command: "postgrest" # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. network_mode: service:db volumes: postgres-data: docker-data: ================================================ FILE: .devcontainer/library-scripts/docker-in-docker-debian.sh ================================================ #!/usr/bin/env bash #------------------------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- # # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md # Maintainer: The Dev Container spec maintainers DOCKER_VERSION="${VERSION:-"latest"}" # The Docker/Moby Engine + CLI should match in version USE_MOBY="${MOBY:-"true"}" MOBY_BUILDX_VERSION="${MOBYBUILDXVERSION:-"latest"}" DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"latest"}" #latest, v2 or none AZURE_DNS_AUTO_DETECTION="${AZUREDNSAUTODETECTION:-"true"}" DOCKER_DEFAULT_ADDRESS_POOL="${DOCKERDEFAULTADDRESSPOOL:-""}" USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" INSTALL_DOCKER_BUILDX="${INSTALLDOCKERBUILDX:-"true"}" INSTALL_DOCKER_COMPOSE_SWITCH="${INSTALLDOCKERCOMPOSESWITCH:-"true"}" MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc" DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES="bookworm buster bullseye bionic focal jammy noble" DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES="bookworm buster bullseye bionic focal hirsute impish jammy noble" # Default: Exit on any failure. set -e # Clean up rm -rf /var/lib/apt/lists/* # Setup STDERR. err() { echo "(!) $*" >&2 } if [ "$(id -u)" -ne 0 ]; then err 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' exit 1 fi ################### # Helper Functions # See: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/shared/utils.sh ################### # Determine the appropriate non-root user if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then USERNAME="" POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)") for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do if id -u ${CURRENT_USER} > /dev/null 2>&1; then USERNAME=${CURRENT_USER} break fi done if [ "${USERNAME}" = "" ]; then USERNAME=root fi elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then USERNAME=root fi apt_get_update() { if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then echo "Running apt-get update..." apt-get update -y fi } # Checks if packages are installed and installs them if not check_packages() { if ! dpkg -s "$@" > /dev/null 2>&1; then apt_get_update apt-get -y install --no-install-recommends "$@" fi } # Figure out correct version of a three part version number is not passed find_version_from_git_tags() { local variable_name=$1 local requested_version=${!variable_name} if [ "${requested_version}" = "none" ]; then return; fi local repository=$2 local prefix=${3:-"tags/v"} local separator=${4:-"."} local last_part_optional=${5:-"false"} if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then local escaped_separator=${separator//./\\.} local last_part if [ "${last_part_optional}" = "true" ]; then last_part="(${escaped_separator}[0-9]+)?" else last_part="${escaped_separator}[0-9]+" fi local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$" local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)" if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)" else set +e declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")" set -e fi fi if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then err "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2 exit 1 fi echo "${variable_name}=${!variable_name}" } # Use semver logic to decrement a version number then look for the closest match find_prev_version_from_git_tags() { local variable_name=$1 local current_version=${!variable_name} local repository=$2 # Normally a "v" is used before the version number, but support alternate cases local prefix=${3:-"tags/v"} # Some repositories use "_" instead of "." for version number part separation, support that local separator=${4:-"."} # Some tools release versions that omit the last digit (e.g. go) local last_part_optional=${5:-"false"} # Some repositories may have tags that include a suffix (e.g. actions/node-versions) local version_suffix_regex=$6 # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios. set +e major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')" minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')" breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')" if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then ((major=major-1)) declare -g ${variable_name}="${major}" # Look for latest version from previous major release find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" # Handle situations like Go's odd version pattern where "0" releases omit the last part elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then ((minor=minor-1)) declare -g ${variable_name}="${major}.${minor}" # Look for latest version from previous minor release find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}" else ((breakfix=breakfix-1)) if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then declare -g ${variable_name}="${major}.${minor}" else declare -g ${variable_name}="${major}.${minor}.${breakfix}" fi fi set -e } # Function to fetch the version released prior to the latest version get_previous_version() { local url=$1 local repo_url=$2 local variable_name=$3 prev_version=${!variable_name} output=$(curl -s "$repo_url"); message=$(echo "$output" | jq -r '.message') if [[ $message == "API rate limit exceeded"* ]]; then echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}" echo -e "\nAttempting to find latest version using GitHub tags." find_prev_version_from_git_tags prev_version "$url" "tags/v" declare -g ${variable_name}="${prev_version}" else echo -e "\nAttempting to find latest version using GitHub Api." version=$(echo "$output" | jq -r '.tag_name') declare -g ${variable_name}="${version#v}" fi echo "${variable_name}=${!variable_name}" } get_github_api_repo_url() { local url=$1 echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest" } ########################################### # Start docker-in-docker installation ########################################### # Ensure apt is in non-interactive to avoid prompts export DEBIAN_FRONTEND=noninteractive # Source /etc/os-release to get OS info . /etc/os-release # Fetch host/container arch. architecture="$(dpkg --print-architecture)" # Check if distro is supported if [ "${USE_MOBY}" = "true" ]; then if [[ "${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS distribution" err "Support distributions include: ${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}" exit 1 fi echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_MOBY_ARCHIVE_VERSION_CODENAMES}'" else if [[ "${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" != *"${VERSION_CODENAME}"* ]]; then err "Unsupported distribution version '${VERSION_CODENAME}'. To resolve, please choose a compatible OS distribution" err "Support distributions include: ${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}" exit 1 fi echo "Distro codename '${VERSION_CODENAME}' matched filter '${DOCKER_LICENSED_ARCHIVE_VERSION_CODENAMES}'" fi # Install dependencies check_packages apt-transport-https curl ca-certificates pigz iptables gnupg2 dirmngr wget jq if ! type git > /dev/null 2>&1; then check_packages git fi # Swap to legacy iptables for compatibility if type iptables-legacy > /dev/null 2>&1; then update-alternatives --set iptables /usr/sbin/iptables-legacy update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy fi # Set up the necessary apt repos (either Microsoft's or Docker's) if [ "${USE_MOBY}" = "true" ]; then # Name of open source engine/cli engine_package_name="moby-engine" cli_package_name="moby-cli" # Import key safely and import Microsoft apt repo curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg echo "deb [arch=${architecture} signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list else # Name of licensed engine/cli engine_package_name="docker-ce" cli_package_name="docker-ce-cli" # Import key safely and import Docker apt repo curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list fi # Refresh apt lists apt-get update # Soft version matching if [ "${DOCKER_VERSION}" = "latest" ] || [ "${DOCKER_VERSION}" = "lts" ] || [ "${DOCKER_VERSION}" = "stable" ]; then # Empty, meaning grab whatever "latest" is in apt repo engine_version_suffix="" cli_version_suffix="" else # Fetch a valid version from the apt-cache (eg: the Microsoft repo appends +azure, breakfix, etc...) docker_version_dot_escaped="${DOCKER_VERSION//./\\.}" docker_version_dot_plus_escaped="${docker_version_dot_escaped//+/\\+}" # Regex needs to handle debian package version number format: https://www.systutorials.com/docs/linux/man/5-deb-version/ docker_version_regex="^(.+:)?${docker_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" set +e # Don't exit if finding version fails - will handle gracefully cli_version_suffix="=$(apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")" engine_version_suffix="=$(apt-cache madison ${engine_package_name} | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${docker_version_regex}")" set -e if [ -z "${engine_version_suffix}" ] || [ "${engine_version_suffix}" = "=" ] || [ -z "${cli_version_suffix}" ] || [ "${cli_version_suffix}" = "=" ] ; then err "No full or partial Docker / Moby version match found for \"${DOCKER_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" apt-cache madison ${cli_package_name} | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' exit 1 fi echo "engine_version_suffix ${engine_version_suffix}" echo "cli_version_suffix ${cli_version_suffix}" fi # Version matching for moby-buildx if [ "${USE_MOBY}" = "true" ]; then if [ "${MOBY_BUILDX_VERSION}" = "latest" ]; then # Empty, meaning grab whatever "latest" is in apt repo buildx_version_suffix="" else buildx_version_dot_escaped="${MOBY_BUILDX_VERSION//./\\.}" buildx_version_dot_plus_escaped="${buildx_version_dot_escaped//+/\\+}" buildx_version_regex="^(.+:)?${buildx_version_dot_plus_escaped}([\\.\\+ ~:-]|$)" set +e buildx_version_suffix="=$(apt-cache madison moby-buildx | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "${buildx_version_regex}")" set -e if [ -z "${buildx_version_suffix}" ] || [ "${buildx_version_suffix}" = "=" ]; then err "No full or partial moby-buildx version match found for \"${MOBY_BUILDX_VERSION}\" on OS ${ID} ${VERSION_CODENAME} (${architecture}). Available versions:" apt-cache madison moby-buildx | awk -F"|" '{print $2}' | grep -oP '^(.+:)?\K.+' exit 1 fi echo "buildx_version_suffix ${buildx_version_suffix}" fi fi # Install Docker / Moby CLI if not already installed if type docker > /dev/null 2>&1 && type dockerd > /dev/null 2>&1; then echo "Docker / Moby CLI and Engine already installed." else if [ "${USE_MOBY}" = "true" ]; then # Install engine set +e # Handle error gracefully apt-get -y install --no-install-recommends moby-cli${cli_version_suffix} moby-buildx${buildx_version_suffix} moby-engine${engine_version_suffix} exit_code=$? set -e if [ ${exit_code} -ne 0 ]; then err "Packages for moby not available in OS ${ID} ${VERSION_CODENAME} (${architecture}). To resolve, either: (1) set feature option '\"moby\": false' , or (2) choose a compatible OS version (eg: 'ubuntu-20.04')." exit 1 fi # Install compose apt-get -y install --no-install-recommends moby-compose || err "Package moby-compose (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." else apt-get -y install --no-install-recommends docker-ce-cli${cli_version_suffix} docker-ce${engine_version_suffix} # Install compose apt-get -y install --no-install-recommends docker-compose-plugin || echo "(*) Package docker-compose-plugin (Docker Compose v2) not available for OS ${ID} ${VERSION_CODENAME} (${architecture}). Skipping." fi fi echo "Finished installing docker / moby!" docker_home="/usr/libexec/docker" cli_plugins_dir="${docker_home}/cli-plugins" # fallback for docker-compose fallback_compose(){ local url=$1 local repo_url=$(get_github_api_repo_url "$url") echo -e "\n(!) Failed to fetch the latest artifacts for docker-compose v${compose_version}..." get_previous_version "${url}" "${repo_url}" compose_version echo -e "\nAttempting to install v${compose_version}" curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} } # If 'docker-compose' command is to be included if [ "${DOCKER_DASH_COMPOSE_VERSION}" != "none" ]; then case "${architecture}" in amd64) target_compose_arch=x86_64 ;; arm64) target_compose_arch=aarch64 ;; *) echo "(!) Docker in docker does not support machine architecture '$architecture'. Please use an x86-64 or ARM64 machine." exit 1 esac docker_compose_path="/usr/local/bin/docker-compose" if [ "${DOCKER_DASH_COMPOSE_VERSION}" = "v1" ]; then err "The final Compose V1 release, version 1.29.2, was May 10, 2021. These packages haven't received any security updates since then. Use at your own risk." INSTALL_DOCKER_COMPOSE_SWITCH="false" if [ "${target_compose_arch}" = "x86_64" ]; then echo "(*) Installing docker compose v1..." curl -fsSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64" -o ${docker_compose_path} chmod +x ${docker_compose_path} # Download the SHA256 checksum DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-Linux-x86_64.sha256" | awk '{print $1}')" echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum sha256sum -c docker-compose.sha256sum --ignore-missing elif [ "${VERSION_CODENAME}" = "bookworm" ]; then err "Docker compose v1 is unavailable for 'bookworm' on Arm64. Kindly switch to use v2" exit 1 else # Use pip to get a version that runs on this architecture check_packages python3-minimal python3-pip libffi-dev python3-venv echo "(*) Installing docker compose v1 via pip..." export PYTHONUSERBASE=/usr/local pip3 install --disable-pip-version-check --no-cache-dir --user "Cython<3.0" pyyaml wheel docker-compose --no-build-isolation fi else compose_version=${DOCKER_DASH_COMPOSE_VERSION#v} docker_compose_url="https://github.com/docker/compose" find_version_from_git_tags compose_version "$docker_compose_url" "tags/v" echo "(*) Installing docker-compose ${compose_version}..." curl -fsSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}" -o ${docker_compose_path} || { if [[ $DOCKER_DASH_COMPOSE_VERSION == "latest" ]]; then fallback_compose "$docker_compose_url" else echo -e "Error: Failed to install docker-compose v${compose_version}" fi } chmod +x ${docker_compose_path} # Download the SHA256 checksum DOCKER_COMPOSE_SHA256="$(curl -sSL "https://github.com/docker/compose/releases/download/v${compose_version}/docker-compose-linux-${target_compose_arch}.sha256" | awk '{print $1}')" echo "${DOCKER_COMPOSE_SHA256} ${docker_compose_path}" > docker-compose.sha256sum sha256sum -c docker-compose.sha256sum --ignore-missing mkdir -p ${cli_plugins_dir} cp ${docker_compose_path} ${cli_plugins_dir} fi fi # fallback method for compose-switch fallback_compose-switch() { local url=$1 local repo_url=$(get_github_api_repo_url "$url") echo -e "\n(!) Failed to fetch the latest artifacts for compose-switch v${compose_switch_version}..." get_previous_version "$url" "$repo_url" compose_switch_version echo -e "\nAttempting to install v${compose_switch_version}" curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/compose-switch } # Install docker-compose switch if not already installed - https://github.com/docker/compose-switch#manual-installation if [ "${INSTALL_DOCKER_COMPOSE_SWITCH}" = "true" ] && ! type compose-switch > /dev/null 2>&1; then if type docker-compose > /dev/null 2>&1; then echo "(*) Installing compose-switch..." current_compose_path="$(which docker-compose)" target_compose_path="$(dirname "${current_compose_path}")/docker-compose-v1" compose_switch_version="latest" compose_switch_url="https://github.com/docker/compose-switch" find_version_from_git_tags compose_switch_version "$compose_switch_url" curl -fsSL "https://github.com/docker/compose-switch/releases/download/v${compose_switch_version}/docker-compose-linux-${architecture}" -o /usr/local/bin/compose-switch || fallback_compose-switch "$compose_switch_url" chmod +x /usr/local/bin/compose-switch # TODO: Verify checksum once available: https://github.com/docker/compose-switch/issues/11 # Setup v1 CLI as alternative in addition to compose-switch (which maps to v2) mv "${current_compose_path}" "${target_compose_path}" update-alternatives --install ${docker_compose_path} docker-compose /usr/local/bin/compose-switch 99 update-alternatives --install ${docker_compose_path} docker-compose "${target_compose_path}" 1 else err "Skipping installation of compose-switch as docker compose is unavailable..." fi fi # If init file already exists, exit if [ -f "/usr/local/share/docker-init.sh" ]; then echo "/usr/local/share/docker-init.sh already exists, so exiting." # Clean up rm -rf /var/lib/apt/lists/* exit 0 fi echo "docker-init doesn't exist, adding..." if ! cat /etc/group | grep -e "^docker:" > /dev/null 2>&1; then groupadd -r docker fi usermod -aG docker ${USERNAME} # fallback for docker/buildx fallback_buildx() { local url=$1 local repo_url=$(get_github_api_repo_url "$url") echo -e "\n(!) Failed to fetch the latest artifacts for docker buildx v${buildx_version}..." get_previous_version "$url" "$repo_url" buildx_version buildx_file_name="buildx-v${buildx_version}.linux-${architecture}" echo -e "\nAttempting to install v${buildx_version}" wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} } if [ "${INSTALL_DOCKER_BUILDX}" = "true" ]; then buildx_version="latest" docker_buildx_url="https://github.com/docker/buildx" find_version_from_git_tags buildx_version "$docker_buildx_url" "refs/tags/v" echo "(*) Installing buildx ${buildx_version}..." buildx_file_name="buildx-v${buildx_version}.linux-${architecture}" cd /tmp wget https://github.com/docker/buildx/releases/download/v${buildx_version}/${buildx_file_name} || fallback_buildx "$docker_buildx_url" docker_home="/usr/libexec/docker" cli_plugins_dir="${docker_home}/cli-plugins" mkdir -p ${cli_plugins_dir} mv ${buildx_file_name} ${cli_plugins_dir}/docker-buildx chmod +x ${cli_plugins_dir}/docker-buildx chown -R "${USERNAME}:docker" "${docker_home}" chmod -R g+r+w "${docker_home}" find "${docker_home}" -type d -print0 | xargs -n 1 -0 chmod g+s fi tee /usr/local/share/docker-init.sh > /dev/null \ << EOF #!/bin/sh #------------------------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- set -e AZURE_DNS_AUTO_DETECTION=${AZURE_DNS_AUTO_DETECTION} DOCKER_DEFAULT_ADDRESS_POOL=${DOCKER_DEFAULT_ADDRESS_POOL} EOF tee -a /usr/local/share/docker-init.sh > /dev/null \ << 'EOF' dockerd_start="AZURE_DNS_AUTO_DETECTION=${AZURE_DNS_AUTO_DETECTION} DOCKER_DEFAULT_ADDRESS_POOL=${DOCKER_DEFAULT_ADDRESS_POOL} $(cat << 'INNEREOF' # explicitly remove dockerd and containerd PID file to ensure that it can start properly if it was stopped uncleanly find /run /var/run -iname 'docker*.pid' -delete || : find /run /var/run -iname 'container*.pid' -delete || : # -- Start: dind wrapper script -- # Maintained: https://github.com/moby/moby/blob/master/hack/dind export container=docker if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then mount -t securityfs none /sys/kernel/security || { echo >&2 'Could not mount /sys/kernel/security.' echo >&2 'AppArmor detection and --privileged mode might break.' } fi # Mount /tmp (conditionally) if ! mountpoint -q /tmp; then mount -t tmpfs none /tmp fi set_cgroup_nesting() { # cgroup v2: enable nesting if [ -f /sys/fs/cgroup/cgroup.controllers ]; then # move the processes from the root group to the /init group, # otherwise writing subtree_control fails with EBUSY. # An error during moving non-existent process (i.e., "cat") is ignored. mkdir -p /sys/fs/cgroup/init xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs || : # enable controllers sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \ > /sys/fs/cgroup/cgroup.subtree_control fi } # Set cgroup nesting, retrying if necessary retry_cgroup_nesting=0 until [ "${retry_cgroup_nesting}" -eq "5" ]; do set +e set_cgroup_nesting if [ $? -ne 0 ]; then echo "(*) cgroup v2: Failed to enable nesting, retrying..." else break fi retry_cgroup_nesting=`expr $retry_cgroup_nesting + 1` set -e done # -- End: dind wrapper script -- # Handle DNS set +e cat /etc/resolv.conf | grep -i 'internal.cloudapp.net' > /dev/null 2>&1 if [ $? -eq 0 ] && [ "${AZURE_DNS_AUTO_DETECTION}" = "true" ] then echo "Setting dockerd Azure DNS." CUSTOMDNS="--dns 168.63.129.16" else echo "Not setting dockerd DNS manually." CUSTOMDNS="" fi set -e if [ -z "$DOCKER_DEFAULT_ADDRESS_POOL" ] then DEFAULT_ADDRESS_POOL="" else DEFAULT_ADDRESS_POOL="--default-address-pool $DOCKER_DEFAULT_ADDRESS_POOL" fi # Start docker/moby engine ( dockerd $CUSTOMDNS $DEFAULT_ADDRESS_POOL > /tmp/dockerd.log 2>&1 ) & INNEREOF )" sudo_if() { COMMAND="$*" if [ "$(id -u)" -ne 0 ]; then sudo $COMMAND else $COMMAND fi } retry_docker_start_count=0 docker_ok="false" until [ "${docker_ok}" = "true" ] || [ "${retry_docker_start_count}" -eq "5" ]; do # Start using sudo if not invoked as root if [ "$(id -u)" -ne 0 ]; then sudo /bin/sh -c "${dockerd_start}" else eval "${dockerd_start}" fi retry_count=0 until [ "${docker_ok}" = "true" ] || [ "${retry_count}" -eq "5" ]; do sleep 1s set +e docker info > /dev/null 2>&1 && docker_ok="true" set -e retry_count=`expr $retry_count + 1` done if [ "${docker_ok}" != "true" ] && [ "${retry_docker_start_count}" != "4" ]; then echo "(*) Failed to start docker, retrying..." set +e sudo_if pkill dockerd sudo_if pkill containerd set -e fi retry_docker_start_count=`expr $retry_docker_start_count + 1` done # Execute whatever commands were passed in (if any). This allows us # to set this script to ENTRYPOINT while still executing the default CMD. exec "$@" EOF chmod +x /usr/local/share/docker-init.sh chown ${USERNAME}:root /usr/local/share/docker-init.sh # Clean up rm -rf /var/lib/apt/lists/* echo 'docker-in-docker-debian script has completed!' ================================================ FILE: .devcontainer/postinstall.sh ================================================ #!/bin/bash echo "Running postinstall.sh" # Aggressively clean npm and corepack caches npm cache clean -f sudo rm -rf /tmp/corepack-cache sudo rm -rf /usr/local/lib/node_modules/corepack # Manually remove global corepack # Reinstall corepack globally via npm npm install -g corepack@latest --force # Install latest corepack version sudo corepack enable # Re-enable corepack # Check corepack version after reinstall corepack --version # Prepare pnpm (again, after corepack reinstall) corepack prepare pnpm@9.14.4 --activate # Go to workspace directory cd /workspaces/webstudio # Configure pnpm store directory pnpm config set store-dir $HOME/.pnpm-store # Clean up directories (optional) find . -name 'node_modules' -type d -prune -exec rm -rf '{}' + find . -name 'lib' -type d -prune -exec rm -rf '{}' + find . -name 'build' -type d -prune -exec rm -rf '{}' + find . -name 'dist' -type d -prune -exec rm -rf '{}' + find . -name '.cache' -type d -prune -exec rm -rf '{}' + find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' + # Install dependencies, build, and migrate pnpm install pnpm build pnpm migrations migrate # Add git aliases cat << 'EOF' >> /home/node/.bashrc alias gitclean="(git remote | xargs git remote prune) && git branch -vv | egrep '('\$(git remote | xargs | sed -e 's/ /|/g')')/.*: gone]' | awk '{print \$1}' | xargs -r git branch -D" alias gitrebase="git rebase --interactive main" EOF # Symlink workspace GitHub instructions to mounted user instructions (for Copilot) if [ -d "/home/node/.github/instructions" ]; then mkdir -p /workspaces/webstudio/.github ln -sfn /home/node/.github/instructions /workspaces/webstudio/.github/instructions fi echo "postinstall.sh finished" ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf ================================================ FILE: .github/actions/add-status/action.yaml ================================================ name: Add status to commit description: Add status to commit inputs: url: description: "URL" required: true title: description: "Title" required: true description: description: "Description" required: true runs: using: "composite" steps: - name: Add URL to vercel deployment through *.prs.webstudio.is uses: actions/github-script@v7 with: script: | const branch = context.payload.pull_request?.head?.ref ?? context.payload.ref?.replace('refs/heads/', '') const sha = context.payload.pull_request?.head?.sha ?? context.sha; const status = { state: 'success', target_url: '${{ inputs.url }}', description: '${{ inputs.description }}', context: '${{ inputs.title }}' }; github.rest.repos.createCommitStatus({ ...context.repo, sha, ...status }); ================================================ FILE: .github/actions/ci-setup/action.yml ================================================ name: CI setup description: | Sets up the CI environment for the project. runs: using: "composite" steps: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile --ignore-scripts shell: bash ================================================ FILE: .github/actions/submodules-checkout/action.yml ================================================ name: CI setup description: | Sets up the CI environment for the project. inputs: submodules-ssh-key: description: "The SSH key to private submodules to use for the checkout" required: true runs: using: "composite" steps: - name: Set up SSH for Git if: ${{ inputs.submodules-ssh-key }} run: | mkdir -p ~/.ssh echo "${{ inputs.submodules-ssh-key }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan github.com >> ~/.ssh/known_hosts shell: bash - name: Verify SSH Connection (Optional) if: ${{ inputs.submodules-ssh-key }} run: | ssh -T git@github.com || true shell: bash - name: Verify SSH Connection (Optional) if: ${{ inputs.submodules-ssh-key }} run: | echo Branch is ${{ github.event.pull_request.head.ref || github.ref_name }} shell: bash - name: Try checkout submodules to the same branch as main repo if: ${{ inputs.submodules-ssh-key }} run: | ./submodules.sh ${{ github.event.pull_request.head.ref || github.ref_name }} shell: bash - name: Show main readme if: ${{ inputs.submodules-ssh-key }} run: | cat ./packages/sdk-components-animation/private-src/README.md || echo "No README found" shell: bash ================================================ FILE: .github/actions/vercel/action.yaml ================================================ name: "VERCEL BUILD AND DEPLOY" description: "Builds and deploy vercel project" inputs: vercel-token: description: "Vercel token" required: true vercel-org-id: description: "Vercel Organization ID" required: true vercel-project-id: description: "Vercel Project ID" required: true ref-name: description: "Branch" required: true sha: description: "Sha" required: true environment: description: "Sha" required: true outputs: domain: description: "Domain" value: ${{ steps.deploy.outputs.domain }} inspect-url: description: "Inspect URL" value: ${{ steps.deploy.outputs.inspect-url }} alias: description: "Alias" value: ${{ steps.alias.outputs.value }} runs: using: "composite" steps: - id: branch run: | CLEAN_NAME="${REF_NAME/.staging/}" CLEAN_NAME=$( echo "${CLEAN_NAME}" | sed 's/[^a-zA-Z0-9_-]//g' | tr A-Z a-z | tr _ - | sed 's/-\{2,\}/-/g' ) echo "value=${CLEAN_NAME}" >> $GITHUB_OUTPUT shell: bash env: REF_NAME: ${{ inputs.ref-name }} - id: short_sha run: | SHORT_SHA=$( echo "value=$(echo ${{ inputs.sha }} | cut -c1-7)" ) echo "value=${SHORT_SHA}" >> $GITHUB_OUTPUT shell: bash - name: CREATE VERCEL PROJECT FILE run: | mkdir -p .vercel cat <<"EOF" > .vercel/project.json { "projectId": "${{ inputs.vercel-project-id }}", "orgId": "${{ inputs.vercel-org-id }}", "settings": { "framework": "remix", "devCommand": "pnpm dev", "installCommand": "pnpm install", "buildCommand": "pnpm --filter=@webstudio-is/http-client build && pnpm --filter=@webstudio-is/builder build", "outputDirectory": null, "rootDirectory": "apps/builder", "directoryListing": false, "nodeVersion": "20.x" } } EOF shell: bash - name: Build run: | export GITHUB_SHA=${{ inputs.sha }} export GITHUB_REF_NAME=${{ inputs.ref-name }} pnpx vercel build shell: bash - name: Patch run: | # When we deploy on Vercel, it generates a URL like webstudio-saas-mahqcavgo-getwebstudio.vercel.app. # We use the alias oauth-wstd-00-staging.vercel.app for routing, which maps to oauth.staging.webstudio.is on the worker. # Issue: Vercel proxies also set x-forwarded-host, which overrides our header. # We are adding x-forwarded-ws-host on the worker as a workaround, but issues with request.url persist. # Remix and the Vercel adapter lack support for header selection https://github.com/vercel/vercel/blob/9d4d4b6deb6294506016106f78e71f1984adcc7f/packages/remix/defaults/server-node.mjs#L44 # Patching server-node.mjs directly without installing Vercel CLI was unsuccessful, so we are using string replacement instead. mapfile -t matching_files < <(grep -rl "req\.headers\['x-forwarded-host'\] || req\.headers\.host" "./apps/builder/build") if [ ${#matching_files[@]} -eq 0 ]; then echo "No files found containing the specified string." exit 1 fi echo "Files containing 'req.headers['x-forwarded-host'] || req.headers.host':" printf '%s\n' "${matching_files[@]}" find ./apps/builder/build -type f -exec sed -i "s/req\.headers\['x-forwarded-host'\] || req\.headers\.host/req.headers['x-forwarded-ws-host'] || req.headers['x-forwarded-host'] || req.headers.host/g" {} + shell: bash - name: Deploy id: deploy run: | pnpx vercel deploy \ --prebuilt \ --token ${{ inputs.vercel-token }} \ 2> >(tee info.txt >&2) | tee domain.txt echo "domain=$(cat ./domain.txt)" >> $GITHUB_OUTPUT echo "inspect-url=$(cat info.txt | grep 'Inspect:' | awk '{print $2}')" >> $GITHUB_OUTPUT shell: bash - name: Set Alias id: alias run: | ALIAS="${{ steps.branch.outputs.value }}" pnpx vercel alias set \ "${{ steps.deploy.outputs.domain }}" \ "${ALIAS}-wstd-00-${{ inputs.environment }}" \ --token ${{ inputs.vercel-token }} \ --scope getwebstudio echo "value=${ALIAS}" >> $GITHUB_OUTPUT shell: bash ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for more information: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates # https://containers.dev/guide/dependabot version: 2 updates: - package-ecosystem: "devcontainers" directory: "/" schedule: interval: weekly ================================================ FILE: .github/pull_request_template.md ================================================ ## Description 1. What is this PR about (link the issue and add a short description) ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file ================================================ FILE: .github/workflows/build-figma-tokens.yml ================================================ name: Build and commit Figma tokens on: push: branches: - figma-tokens paths: - packages/design-system/src/__generated__/figma-design-tokens.json jobs: main: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: # We don't need a token to push from an action, # but we need it if want the commit to trigger other workflows as normal token: ${{ secrets.ACCESS_TOKEN_FOR_FIGMA_TOKENS }} - uses: ./.github/actions/ci-setup - name: Configure git run: | git config --global user.name 'Bot (build-figma-tokens.yml)' git config --global user.email 'bot@localhost' - name: Switch branch run: git checkout figma-tokens - name: Build tokens run: pnpm build-figma-tokens - name: Commit and push run: | [[ -z `git status | grep figma-design-tokens.ts` ]] || git commit -m "Update figma-design-tokens.ts" packages/design-system/src/__generated__/figma-design-tokens.ts git push ================================================ FILE: .github/workflows/check-submodules.yml ================================================ name: Check submodules on: pull_request: # cancel in-progress runs on new commits to same PR (gitub.event.number) concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: true permissions: contents: read # to fetch code (actions/checkout) jobs: checks: timeout-minutes: 20 environment: name: development env: DATABASE_URL: postgres:// AUTH_SECRET: test runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - name: Check if any submodule branch matches github.ref_name run: | echo "C ${{ github.workflow }}-${{ github.event.number || github.sha }}" # Get the current branch or tag name REF_NAME="${{ github.event.pull_request.head.ref || github.ref_name }}" echo "Branch is:" $REF_NAME # List all submodule paths SUBMODULES=$(git submodule status | awk '{print $2}') # Check each submodule's branch for SUBMODULE in $SUBMODULES; do echo "Checking submodule: $SUBMODULE" ( cd "$SUBMODULE" # Get the current branch of the submodule SUBMODULE_BRANCH=$(git rev-parse --abbrev-ref HEAD) echo "Submodule branch: $SUBMODULE_BRANCH" # Compare the submodule branch to the ref_name if [ "$SUBMODULE_BRANCH" = "$REF_NAME" ]; then echo "::error::Submodule '$SUBMODULE' is on branch '$SUBMODULE_BRANCH', which matches the current ref '$REF_NAME'." exit 1 fi ) if [ $? -ne 0 ]; then exit 1 # Fail the workflow if any submodule branch matches fi done echo "No submodule is on the same branch as the current ref '$REF_NAME'." ================================================ FILE: .github/workflows/chromatic.yml ================================================ name: Chromatic on: push: branches: - main pull_request: pull_request_target: types: [labeled] # cancel in-progress runs on new commits to same PR (gitub.event.number) concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: true permissions: contents: read # to fetch code (actions/checkout) jobs: chromatic: # Run on push, regular PR (for repo branches), or labeled PR with safe-to-deploy (for forks) if: | github.event_name == 'push' || github.event_name == 'pull_request' || (github.event_name == 'pull_request_target' && github.event.label.name == 'safe-to-deploy') timeout-minutes: 20 runs-on: ubuntu-latest environment: name: development steps: - uses: actions/checkout@v4 with: fetch-depth: 2 # we need to fetch at least parent commit to satisfy Chromatic ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit # Storybook with submodules - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: ./.github/actions/ci-setup - name: Chromatic id: chromatic uses: chromaui/action@v11.3.0 with: projectToken: bea8dc1981d4 buildScriptName: storybook:build ================================================ FILE: .github/workflows/cli-r2-static.yaml ================================================ name: CLI R2 SSG on: push: branches: - "*.staging" # cancel in-progress runs on new commits to same PR (gitub.event.number) concurrency: group: vercel-cli-r2-static-${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: true permissions: contents: read # to fetch code (actions/checkout) statuses: write # This is required for the GitHub Script createCommitStatus to work packages: write jobs: build: env: COMPATIBILITY_DATE: 2024-04-10 environment: name: "staging" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.sha }} # HEAD commit instead of merge commit # We need submodules here as this is used for the cloudflare build - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm # TRY FIX cloudlare incident - uses: unfor19/install-aws-cli-action@v1 with: version: "2.22.35" verbose: false arch: amd64 - name: pnpm instal run: pnpm install --ignore-scripts - name: pnpm build run: pnpm --filter 'ssg^...' run build # Ideally, execute 'pnpm deploy --prod', but @remix-run/dev doesn't support this flag. # Despite being listed as a dependency, @remix-run/dev does not install the remix cli. # TODO: Minimize artefact size due to frequent downloads on each publish. - name: pnpm deploy run: pnpm --filter 'ssg' deploy "${{ github.workspace }}/../ssg-template" - name: Make archive run: | tar --use-compress-program="zstd -19" -cf ssg-template.tar.zst ssg-template working-directory: ${{ github.workspace }}/.. - name: Copy artifact run: | # For staging aws s3 cp ssg-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/ssg-template/${{ github.ref_name }}.tar.zst" # For production can be cached forever aws s3 cp \ --metadata-directive REPLACE --cache-control "public,max-age=31536102,immutable" \ ssg-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/ssg-template/${{ github.sha }}.tar.zst" working-directory: ${{ github.workspace }}/.. env: AWS_ENDPOINT_URL_S3: ${{ secrets.AWS_ENDPOINT_URL_S3 }} AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} ARTEFACT_BUCKET_NAME: ${{ secrets.ARTEFACT_BUCKET_NAME }} checks: environment: name: "staging" runs-on: ubuntu-latest needs: build steps: - uses: pnpm/action-setup@v4 with: version: "9" - uses: actions/setup-node@v4 with: node-version: 20 - name: Copy atrifact via http run: curl -o ssg-template.tar.zst ${{ secrets.ARTEFACT_BUCKET_URL }}/public/ssg-template/${{ github.ref_name }}.tar.zst - name: Extract archive run: tar --use-compress-program="zstd -d" -xf ssg-template.tar.zst -C . - name: Webstudio Build run: pnpm webstudio build --template ssg --template internal working-directory: ${{ github.workspace }}/ssg-template - name: Build run: pnpm build working-directory: ${{ github.workspace }}/ssg-template ================================================ FILE: .github/workflows/cli-r2.yaml ================================================ name: CLI R2 on: push: branches: - "*.staging" # cancel in-progress runs on new commits to same PR (gitub.event.number) concurrency: group: vercel-cli-r2-${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: true permissions: contents: read # to fetch code (actions/checkout) statuses: write # This is required for the GitHub Script createCommitStatus to work packages: write deployments: write jobs: build: env: COMPATIBILITY_DATE: 2024-04-10 environment: name: "staging" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.sha }} # HEAD commit instead of merge commit # We need submodules here as this is used for the cloudflare build - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm # TRY FIX cloudlare incident - uses: unfor19/install-aws-cli-action@v1 with: version: "2.22.35" verbose: false arch: amd64 - name: pnpm instal run: pnpm install --ignore-scripts - name: pnpm build run: pnpm --filter 'webstudio-cloudflare-template^...' run build # Ideally, execute 'pnpm deploy --prod', but @remix-run/dev doesn't support this flag. # Despite being listed as a dependency, @remix-run/dev does not install the remix cli. # TODO: Minimize artefact size due to frequent downloads on each publish. - name: pnpm deploy run: pnpm --filter 'webstudio-cloudflare-template' deploy "${{ github.workspace }}/../cloudflare-template" - name: Make archive run: | tar --use-compress-program="zstd -19" -cf cloudflare-template.tar.zst cloudflare-template working-directory: ${{ github.workspace }}/.. - name: Copy artifact run: | # For staging aws s3 cp cloudflare-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/cloudflare-template/${{ github.ref_name }}.tar.zst" # For production can be cached forever aws s3 cp \ --metadata-directive REPLACE --cache-control "public,max-age=31536102,immutable" \ cloudflare-template.tar.zst "s3://${ARTEFACT_BUCKET_NAME}/public/cloudflare-template/${{ github.sha }}.tar.zst" working-directory: ${{ github.workspace }}/.. env: AWS_ENDPOINT_URL_S3: ${{ secrets.AWS_ENDPOINT_URL_S3 }} AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} ARTEFACT_BUCKET_NAME: ${{ secrets.ARTEFACT_BUCKET_NAME }} checks: environment: name: "staging" runs-on: ubuntu-latest needs: build steps: - uses: pnpm/action-setup@v4 with: version: "9" - uses: actions/setup-node@v4 with: node-version: 20 - name: Copy atrifact via http run: curl -o cloudflare-template.tar.zst ${{ secrets.ARTEFACT_BUCKET_URL }}/public/cloudflare-template/${{ github.ref_name }}.tar.zst - name: Extract archive run: tar --use-compress-program="zstd -d" -xf cloudflare-template.tar.zst -C . - name: Webstudio Build run: pnpm webstudio build --template internal --template saas-helpers --template cloudflare --assets false working-directory: ${{ github.workspace }}/cloudflare-template - name: Remix Build run: pnpm build working-directory: ${{ github.workspace }}/cloudflare-template - name: WRANGLER Build run: | NODE_ENV=production pnpm wrangler deploy \ --name build \ --compatibility-date '${COMPATIBILITY_DATE}' \ --minify true \ --logpush true \ --dry-run \ --outdir dist \ './functions/[[path]].ts' working-directory: ${{ github.workspace }}/cloudflare-template delete-github-deployments: needs: checks uses: ./.github/workflows/delete-github-deployments.yml with: ref: ${{ github.ref_name }} ================================================ FILE: .github/workflows/delete-github-deployments.yml ================================================ # https://github.com/orgs/community/discussions/36919 name: Delete github deployments on: workflow_call: inputs: ref: type: string required: true permissions: deployments: write jobs: delete_github_deployments: runs-on: ubuntu-latest if: ${{ always() }} steps: - name: Delete Previous deployments uses: actions/github-script@v7 env: REF: ${{ inputs.ref }} with: script: | const { REF } = process.env; console.log(REF); const deployments = await github.rest.repos.listDeployments({ owner: context.repo.owner, repo: context.repo.repo, ref: REF, per_page: 100 }); console.log(deployments); await Promise.allSettled( deployments.data.map(async (deployment) => { await github.rest.repos.createDeploymentStatus({ owner: context.repo.owner, repo: context.repo.repo, deployment_id: deployment.id, state: 'inactive' }); return github.rest.repos.deleteDeployment({ owner: context.repo.owner, repo: context.repo.repo, deployment_id: deployment.id }); }) ); ================================================ FILE: .github/workflows/fixtures-test.yml ================================================ name: Fixtures tests on: workflow_call: inputs: builder-url: required: true type: string builder-host: required: true type: string environment: required: true type: string secrets: PRIVATE_GITHUB_DEPLOY_TOKEN: required: true permissions: contents: read # to fetch code (actions/checkout) jobs: checks: timeout-minutes: 20 strategy: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} environment: name: ${{ inputs.environment }} env: DATABASE_URL: postgres:// AUTH_SECRET: test BUILDER_URL_DEPRECATED: ${{ inputs.builder-url }} BUILDER_HOST: ${{ inputs.builder-host }} steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} # Test that everything is working with submodules - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: ./.github/actions/ci-setup # Testing fixtures for vercel template - name: Test cli --help flag working-directory: ./fixtures/webstudio-features run: pnpm cli --help - name: Testing cli link command run: pnpm --filter='./fixtures/*' --sequential run fixtures:link - name: Testing cli sync command run: pnpm --filter='./fixtures/*' run --parallel fixtures:sync - name: Testing cli build command run: pnpm --filter='./fixtures/*' run --parallel fixtures:build - name: Prepare for diffing shell: bash run: | find . -type f -path "./fixtures/*/.webstudio/data.json" -exec sed -i 's|"origin": ".*"|"origin": "https://main.development.webstudio.is"|g' {} + - name: Test git diff # This command will fail if there are uncommitted changes, i.e something has broken run: git diff --name-only HEAD --exit-code - name: Show changed files and diff if: ${{ failure() }} run: | echo "Changed files are:" git diff --name-only HEAD git diff HEAD | head -n 1000 ================================================ FILE: .github/workflows/lint-pull-request.yaml ================================================ name: "Lint PR" on: pull_request: types: - opened - edited - synchronize pull_request_target: types: - opened - edited - synchronize permissions: pull-requests: write jobs: main: name: Validate PR title runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title with: # Configure which types are allowed (newline-delimited). # Default: https://github.com/commitizen/conventional-commit-types types: | feat fix docs style refactor perf test build ci chore revert experimental env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - if: always() && (steps.lint_pr_title.outputs.error_message != null) uses: marocchino/sticky-pull-request-comment@v2 # When the previous steps fails, the workflow would stop. By adding this # condition you can continue the execution with the populated error message. with: header: pr-title-lint-error message: | Hey there and thank you for opening this pull request! 👋🏼 We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. Details: ``` ${{ steps.lint_pr_title.outputs.error_message }} ```
Release types - **feat** - A new feature - **fix** - A bug fix - **docs** - Documentation only changes - **style** - Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) - **refactor** - A code change that neither fixes a bug nor adds a feature - **perf** - A code change that improves performance - **test** - Adding missing tests or correcting existing tests - **build** - Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) - **ci** - Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) - **chore** - Other changes that don't modify src or test files - **revert** - Reverts a previous commit - **experimental** - Flagged feature
# Delete a previous comment when the issue has been resolved - if: ${{ steps.lint_pr_title.outputs.error_message == null }} uses: marocchino/sticky-pull-request-comment@v2 with: header: pr-title-lint-error delete: true ================================================ FILE: .github/workflows/main.yml ================================================ name: Main workflow on: push: branches: - main pull_request: pull_request_target: types: [labeled] # cancel in-progress runs on new commits to same PR (gitub.event.number) concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: true permissions: contents: read # to fetch code (actions/checkout) jobs: checks: # Run on push, regular PR (for repo branches), or labeled PR with safe-to-deploy (for forks) if: | github.event_name == 'push' || github.event_name == 'pull_request' || (github.event_name == 'pull_request_target' && github.event.label.name == 'safe-to-deploy') timeout-minutes: 20 strategy: matrix: environment: - empty - development environment: name: ${{ matrix.environment }} env: DATABASE_URL: postgres:// AUTH_SECRET: test runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} # Will not checkout submodules on empty environment, and will on development - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: Pnpm install run: | pnpm install - uses: actions/cache@v4 with: path: | ./node_modules/.cache/prettier/.prettier-cache key: checks-${{ github.sha }} restore-keys: checks- - run: echo ===SHA USED=== ${{ github.event.pull_request.head.sha || github.sha }} # todo: remove after check whats happening on main - run: | pnpm prettier --cache --check "**/*.{js,md,ts,tsx}" - name: Lint run: | pnpm lint - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | playwright-${{ runner.os }}- - name: Playwright init run: | pnpm playwright install working-directory: packages/sdk-components-animation - name: Test run: | pnpm -r test - name: Typecheck run: | pnpm -r typecheck check-size: runs-on: ubuntu-24.04-arm environment: name: development steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: ./.github/actions/ci-setup - run: pnpm --filter "{./fixtures/*}..." build - uses: actions/github-script@v7 with: script: | const assertSize = async (directory, maxSize) => { let result = '' await exec.exec('du', ['-sk', directory], { silent: true, listeners: { stdout: (data) => { result += data.toString() } } }) const size = Number.parseInt(result, 10) return { passed: size <= maxSize, size, diff: size - maxSize, directory, } } const results = [ await assertSize('./fixtures/ssg/dist/client', 356), await assertSize('./fixtures/react-router-netlify/build/client', 376), await assertSize('./fixtures/webstudio-features/build/client', 3312), ] for (const result of results) { if (result.passed) { console.info(`${result.directory}: ${result.size}kB (${result.diff}kB)`) } else { console.info('') console.error(`${result.directory}: ${result.size}kB (+${result.diff}kB)`) } } if (results.some(result => result.passed === false)) { console.error('Some fixtures exceeded limits') process.exit(1) } ================================================ FILE: .github/workflows/migrate.yaml ================================================ name: Migrate on: push: branches: - "migrate" - "main" - "*.staging" - "*.migrate" # Pending if other migration from the same branch is running concurrency: migrate-${{ github.ref_name }} permissions: contents: read # to fetch code (actions/checkout) statuses: write # This is required for the GitHub Script createCommitStatus to work jobs: migrate: # This workflow is triggered only on pushes to the `main`, `*.staging` or 'migrate' branches. # For `*.staging` and `migrate` it specifically checks if the commit message starts with `::migrate::`, # indicating a migration-related change. # # Example usage: # Execute a commit with a migration flag using: # git commit --allow-empty -m "::migrate::test description" # Note: # This setup is a temporary measure. The intention is to transition to a fully automated publish and release process via GitHub Actions in the future. if: (github.ref_name == 'main') || ((github.ref_name == 'migrate' || endsWith(github.ref_name, '.migrate') || endsWith(github.ref_name, '.staging')) && startsWith(github.event.head_commit.message, '::migrate::')) runs-on: ubuntu-latest environment: name: ${{ (startsWith(github.ref_name, 'release') && endsWith(github.ref_name, '.staging')) && 'postgres_production' || 'postgres_development' }} timeout-minutes: 20 steps: - uses: actions/checkout@v4 with: ref: ${{ github.sha }} # HEAD commit instead of merge commit - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: pnpm instal run: pnpm install --ignore-scripts - name: generate prisma run: pnpm --filter=@webstudio-is/prisma-client generate - name: execute migration run: pnpm --filter '@webstudio-is/prisma-client' run migrations migrate env: DIRECT_URL: ${{ secrets.DIRECT_URL }} # Execute db tests (runs after migrations, even if they fail) db-tests: # Always run tests to see failures on CI, but only after migrate job completes if: always() needs: [migrate] runs-on: ubuntu-latest environment: name: ${{ (startsWith(github.ref_name, 'release') && endsWith(github.ref_name, '.staging')) && 'postgres_production' || 'postgres_development' }} steps: - uses: actions/checkout@v4 with: ref: ${{ github.sha }} # HEAD commit instead of merge commit - uses: pnpm/action-setup@v4 - name: Run database tests run: pnpm -r db-test env: DIRECT_URL: ${{ secrets.DIRECT_URL }} # Prints pending migrations pending: if: always() needs: [migrate] runs-on: ubuntu-latest environment: name: ${{ (startsWith(github.ref_name, 'release') && endsWith(github.ref_name, '.staging')) && 'postgres_production' || 'postgres_development' }} steps: - uses: actions/checkout@v4 with: ref: ${{ github.sha }} # HEAD commit instead of merge commit - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: pnpm instal run: pnpm install --ignore-scripts - name: generate prisma run: pnpm --filter=@webstudio-is/prisma-client generate - name: get pending id: pending run: | echo "value=$(pnpm --filter '@webstudio-is/prisma-client' run migrations pending-count | grep ::pending-count::)" >> $GITHUB_OUTPUT env: DIRECT_URL: ${{ secrets.DIRECT_URL }} - uses: ./.github/actions/add-status with: title: "⭕ Pending Migrations" description: ${{ steps.pending.outputs.value }} url: "https://webstudio.is" ================================================ FILE: .github/workflows/publish-beta.yml ================================================ name: Publish beta packages on NPM 📦 on: pull_request: types: - labeled jobs: publish: # prevents this action from running on forks if: | github.repository_owner == 'webstudio-is' && startsWith(github.event.label.name, 'publish:') timeout-minutes: 20 runs-on: ubuntu-latest env: DATABASE_URL: postgres:// AUTH_SECRET: test steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - name: Creating .npmrc run: | cat << EOF > "$HOME/.npmrc" //registry.npmjs.org/:_authToken=$NPM_TOKEN EOF env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # compute short sha - id: short_sha run: echo "value=$(echo ${{ github.event.pull_request.head.sha || github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT - id: tag run: echo "value=$(echo ${{ github.event.label.name }} | cut -d ':' -f2)" >> $GITHUB_OUTPUT - name: bump version to 0.0.0-${{ steps.short_sha.outputs.value }} run: | pnpx replace-in-files-cli \ --string="0.0.0-webstudio-version" \ --replacement="0.0.0-${{ steps.short_sha.outputs.value }}" \ "**/package.json" - run: pnpm install --ignore-scripts - run: pnpm --filter="webstudio..." build - run: pnpm --filter="webstudio..." dts - name: Publishing ${{ steps.tag.outputs.value }} tag with sha ${{ steps.short_sha.outputs.value }} run: pnpm -r publish --tag "${{ steps.tag.outputs.value }}" --no-git-checks --access public ================================================ FILE: .github/workflows/re-create-figma-tokens-branch.yml ================================================ name: Re-create branch for Figma tokens on: delete permissions: contents: write jobs: main: runs-on: ubuntu-latest # run if figma-tokens was deleted if: ${{ github.event.ref == 'figma-tokens'}} steps: - name: Checkout uses: actions/checkout@v4 - name: Re-create branch run: | git checkout main git checkout -b figma-tokens git push --set-upstream origin figma-tokens ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - '[0-9]+.[0-9]+.[0-9]+' jobs: release: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/github-script@v7 with: script: | const latestRelease = await github.rest.repos.getLatestRelease({ owner: context.repo.owner, repo: context.repo.repo, }) const commits = await github.rest.repos.compareCommitsWithBasehead({ owner: context.repo.owner, repo: context.repo.repo, basehead: `refs/tags/${latestRelease.data.tag_name}...${context.ref}`, }) const groups = { feat: [`## Features\n`], fix: [`## Fixes\n`], docs: [`## Documentation\n`], experimental: [`## Experimental\n`], other: [`## Other changes\n`], } for (const commit of commits.data.commits) { const match = commit.commit.message.match(/^(?\w+)\s*:\s*(?.+)\n*/) const type = match?.groups?.type const message = match?.groups?.message if (type && message) { const availableType = type in groups ? type : 'other' const capitalized = message[0].toLocaleUpperCase() + message.slice(1) groups[availableType].push(`- ${capitalized} by @${commit.author.login}`) } } const tag_name = context.ref.slice('refs/tags/'.length) const fullChangelog = `**Full Changelog**: https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${latestRelease.data.tag_name}...${tag_name}` const changelog = Object.values(groups) .filter(lines => lines.length > 1) .map(lines => lines.join('\n')) .concat(fullChangelog) .join('\n\n') console.info(changelog) await github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, tag_name, name: tag_name, body: changelog, }) publish: runs-on: ubuntu-latest permissions: contents: write environment: name: development steps: - uses: actions/checkout@v4 with: ref: ${{ github.ref }} # tag name - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - id: version run: echo "value=$(echo ${{ github.ref }} | sed 's/refs\/tags\///')" >> $GITHUB_OUTPUT - name: bump version to ${{ steps.version.outputs.value }} run: | pnpx replace-in-files-cli \ --string="0.0.0-webstudio-version" \ --replacement="${{ steps.version.outputs.value }}" \ "**/package.json" - name: pnpm instal run: pnpm install --ignore-scripts - run: pnpm --filter="webstudio..." build - run: pnpm --filter="webstudio..." dts - name: Creating .npmrc run: | cat << EOF > "$HOME/.npmrc" //registry.npmjs.org/:_authToken=$NPM_TOKEN EOF env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm -r publish --access public --no-git-checks ================================================ FILE: .github/workflows/vercel-deploy-staging.yml ================================================ name: Vercel Deploy Staging on: push: pull_request_target: types: [labeled, synchronize] # cancel in-progress runs on new commits to same PR (gitub.event.number) concurrency: group: vercel-deploy-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true permissions: contents: read # to fetch code (actions/checkout) statuses: write # This is required for the GitHub Script createCommitStatus to work deployments: write pull-requests: write # needed to remove labels and comment jobs: # Remove the safe-to-deploy label when new commits are pushed to a PR (requires re-review) remove-label-on-update: if: github.event_name == 'pull_request_target' && github.event.action == 'synchronize' runs-on: ubuntu-latest steps: - name: Remove safe-to-deploy label uses: actions/github-script@v7 with: script: | await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'safe-to-deploy' }).catch(() => {}); console.log('Removed safe-to-deploy label due to new commits. Re-review required.'); deployment: # Run on push (for repo branches) OR on labeled event with safe-to-deploy label (for fork PRs) if: | github.event_name == 'push' || (github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'safe-to-deploy') # Execute development and staging on staging branches # Execute only development on all other branches strategy: matrix: environment: - staging - development is-staging: - ${{ github.event_name == 'push' && endsWith(github.ref_name, '.staging') }} exclude: - environment: staging is-staging: false environment: name: ${{ matrix.environment }} timeout-minutes: 20 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }} - uses: ./.github/actions/submodules-checkout with: submodules-ssh-key: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - uses: ./.github/actions/vercel id: vercel name: Deploy to Vercel with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} ref-name: ${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} sha: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }} environment: ${{ matrix.environment }} - name: Debug Vercel Outputs run: | echo "domain=${{ steps.vercel.outputs.domain }}" echo "inspect-url=${{ steps.vercel.outputs.inspect-url }}" echo "alias=${{ steps.vercel.outputs.alias }}" - uses: ./.github/actions/add-status with: title: "⏰ [${{ matrix.environment }}] Vercel Inspection" description: "[${{ matrix.environment }}] Vercel logs" url: "${{ steps.vercel.outputs.inspect-url }}" - uses: ./.github/actions/add-status with: title: "⭐ [${{ matrix.environment }}] Apps Webstudio URL" description: "[${{ matrix.environment }}] Site url" url: "https://${{ steps.vercel.outputs.alias }}.${{ matrix.environment }}.webstudio.is" - name: Comment on PR with deployment URL if: github.event_name == 'pull_request_target' && matrix.environment == 'development' uses: actions/github-script@v7 with: script: | const deployUrl = 'https://${{ steps.vercel.outputs.alias }}.${{ matrix.environment }}.webstudio.is'; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: `🚀 **Deployed!**\n\n📍 Preview: ${deployUrl}\n\n_Note: Adding new commits will remove the \`safe-to-deploy\` label and require re-approval._` }); outputs: builder-url: "https://${{ steps.vercel.outputs.alias }}.${{ matrix.environment }}.webstudio.is" builder-host: "${{ steps.vercel.outputs.alias }}.${{ matrix.environment }}.webstudio.is" fixtures-test: needs: deployment uses: ./.github/workflows/fixtures-test.yml with: builder-url: ${{ needs.deployment.outputs.builder-url }} builder-host: ${{ needs.deployment.outputs.builder-host }} environment: development secrets: # We are not passing the secret here (as it does not exist in the current environment). # Instead, this serves as a signal to the calling workflow that it has permission to extract it from the environment. PRIVATE_GITHUB_DEPLOY_TOKEN: ${{ secrets.PRIVATE_GITHUB_DEPLOY_TOKEN }} delete-github-deployments: needs: fixtures-test uses: ./.github/workflows/delete-github-deployments.yml with: ref: ${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} ================================================ FILE: .github/workflows/vis-reg-tests.yml ================================================ name: Visual Regression Tests on: push: branches: - main pull_request: pull_request_target: types: [labeled] # cancel in-progress runs on new commits to same PR (gitub.event.number) concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: true permissions: contents: read # to fetch code (actions/checkout) jobs: lost-pixel: # Run on push, regular PR (for repo branches), or labeled PR with safe-to-deploy (for forks) if: | github.event_name == 'push' || github.event_name == 'pull_request' || (github.event_name == 'pull_request_target' && github.event.label.name == 'safe-to-deploy') timeout-minutes: 20 runs-on: ubuntu-latest env: DATABASE_URL: postgres:// AUTH_SECRET: test steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} # HEAD commit instead of merge commit - uses: ./.github/actions/ci-setup - run: VISUAL_TESTING=true pnpm storybook:build - name: Lost Pixel uses: lost-pixel/lost-pixel@v3.16.0 env: LOST_PIXEL_API_KEY: 8b76db6c-b9f0-46d1-982f-70900a02690a ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules .pnp .pnp.js yarn.lock # testing coverage # remix build _build .cache .vercel .output .netlify # misc .DS_Store *.pem !https/*.pem /.idea # logs npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files # .env .env.development .env.local .env.development.local .env.test.local .env.production.local # data /data *.db *.db-journal # migrations **/prisma/migrations/lockfile **/prisma/migrations/*/client # builds packages/**/lib generated storybook-static tsconfig.tsbuildinfo # to save thunder https://marketplace.visualstudio.com/items?itemName=rangav.vscode-thunder-client files .thunder .env*.local # wrangler builds dist # should be here otherwise if placed inside prisma-client pnpm deploy doesn't copy it packages/prisma-client/src/__generated__ .temp *.timestamp-*.mjs # copilot instructions .github/instructions ================================================ FILE: .gitmodules ================================================ [submodule "packages/sdk-components-animation/private-src"] path = packages/sdk-components-animation/private-src url = git@github.com:webstudio-is/sdk-components-animation.git branch = main ================================================ FILE: .nvmrc ================================================ 22 ================================================ FILE: .oxlintrc.json ================================================ { "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", "plugins": ["react", "unicorn", "typescript"], "categories": { "correctness": "off" }, "rules": { "no-console": [ "error", { "allow": ["info", "warn", "error", "time", "timeEnd"] } ], "func-style": ["error", "expression", { "allowArrowFunctions": true }], "curly": "error", "eqeqeq": ["error", "always", { "null": "ignore" }], "radix": "error", "react/rules-of-hooks": "error", "react/exhaustive-deps": "warn", "typescript/no-explicit-any": "error", "unicorn/filename-case": ["error", { "case": "kebabCase" }], "unicorn/prefer-node-protocol": "error" }, "ignorePatterns": [ "**/*.js", "**/*.d.ts", "**/__generated__/**", "codemod/**", "packages/*/lib/**", "packages/prisma-client/prisma/migrations/**", "packages/cli/templates/**", "fixtures/**", "packages/sdk-components-animation/private-src/polyfill/**", "packages/sdk-components-animation/private-src/perf/**" ] } ================================================ FILE: .prettierignore ================================================ pnpm-lock.yaml packages/prisma-client/prisma/migrations/*/client packages/prisma-client/**/*.d.ts ================================================ FILE: .storybook/main.ts ================================================ import * as path from "node:path"; import { existsSync, readdirSync } from "node:fs"; import { defaultClientConditions } from "vite"; import type { StorybookConfig } from "@storybook/react-vite"; const isFolderEmpty = (folderPath: string) => { if (!existsSync(folderPath)) { return true; // Folder does not exist } const contents = readdirSync(folderPath); return contents.length === 0; }; const hasPrivateFolders = !isFolderEmpty( path.join(__dirname, "../../packages/sdk-components-animation/private-src") ); const visualTestingStories: StorybookConfig["stories"] = [ { directory: "../apps/builder", titlePrefix: "Builder", files: "**/*.stories.tsx", }, { directory: "../packages/design-system/src/components", titlePrefix: "Design system", files: "**/*.stories.tsx", }, ]; export default { stories: process.env.VISUAL_TESTING ? visualTestingStories : [ ...visualTestingStories, { directory: "../packages/css-engine/src", titlePrefix: "CSS engine", files: "**/*.stories.tsx", }, { directory: "../packages/image/src", titlePrefix: "Image", files: "**/*.stories.tsx", }, { directory: "../packages/icons", titlePrefix: "Icons", files: "**/*.stories.tsx", }, { directory: "../packages/sdk-components-react", titlePrefix: "SDK components React", files: "**/*.stories.tsx", }, { directory: "../packages/sdk-components-react-radix", titlePrefix: "SDK components React Radix", files: "**/*.stories.tsx", }, { directory: "../packages/sdk-components-animation", titlePrefix: "SDK components animation", files: "**/*.stories.tsx", }, ], framework: { name: "@storybook/react-vite", options: {}, }, addons: [ "@storybook/addon-controls", "@storybook/addon-actions", "@storybook/addon-backgrounds", ], async viteFinal(config) { return { ...config, optimizeDeps: { exclude: ["scroll-timeline-polyfill"], }, define: { ...config.define, // storybook use "util" package internally which is bundled with stories // and gives an error that process is undefined "process.env.NODE_DEBUG": "undefined", "process.env.IS_STROYBOOK": "true", }, resolve: { ...config.resolve, conditions: hasPrivateFolders ? ["webstudio-private", "webstudio", ...defaultClientConditions] : ["webstudio", ...defaultClientConditions], alias: [ { find: "~", replacement: path.resolve("./apps/builder/app"), }, ], }, }; }, } satisfies StorybookConfig; ================================================ FILE: .storybook/preview-body.html ================================================ ================================================ FILE: .storybook/preview.tsx ================================================ import type { Preview } from "@storybook/react"; import * as React from "react"; import { useEffect } from "react"; import { TooltipProvider } from "@radix-ui/react-tooltip"; import { setEnv } from "../packages/feature-flags/src/index"; import { theme, globalCss } from "../packages/design-system/src/index"; import { color } from "../packages/design-system/src/__generated__/figma-design-tokens"; // this adds ); const angleKeyframes = keyframes({ to: { [angleVar]: "360deg", }, }); const baseOutlineStyle = css({ borderWidth: 1, variants: { variant: { default: { borderStyle: "solid", borderColor: `oklch(from ${theme.colors.backgroundPrimary} l c h / 0.7)`, }, collaboration: { [angleVar]: `0deg`, borderStyle: "solid", borderImage: `conic-gradient(from var(${angleVar}), #39FBBB 0%, #4A4EFA 12.5%, #E63CFE 25%, #FFAE3C 37.5%, #39FBBB 50%, #4A4EFA 62.5%, #E63CFE 75%, #FFAE3C 87.5%) 1`, animation: `2s ${angleKeyframes} linear infinite`, }, slot: { borderStyle: "solid", borderColor: theme.colors.foregroundReusable, }, }, isLeftClamped: { true: { borderLeftWidth: 0, }, }, isRightClamped: { true: { borderRightWidth: 0, }, }, isBottomClamped: { true: { borderBottomWidth: 0, }, }, isTopClamped: { true: { borderTopWidth: 0, }, }, }, defaultVariants: { variant: "default" }, }); const baseStyle = css({ boxSizing: "border-box", position: "absolute", display: "grid", pointerEvents: "none", top: 0, left: 0, }); const useDynamicStyle = (rect?: Rect) => { return useMemo(() => { if (rect === undefined) { return; } return { transform: `translate3d(${rect.left}px, ${rect.top}px, 0)`, width: rect.width, height: rect.height, }; }, [rect]); }; type OutlineProps = { children?: ReactNode; rect: Rect; clampingRect: Rect; variant?: "default" | "collaboration" | "slot"; }; export const Outline = ({ children, rect, clampingRect, variant, }: OutlineProps) => { const outlineRect = { top: Math.max(rect.top, clampingRect.top), height: Math.min(rect.top + rect.height, clampingRect.top + clampingRect.height) - Math.max(rect.top, clampingRect.top), left: Math.max(rect.left, clampingRect.left), width: Math.min(rect.left + rect.width, clampingRect.left + clampingRect.width) - Math.max(rect.left, clampingRect.left), }; const dynamicStyle = useDynamicStyle(outlineRect); if (outlineRect.width <= 0 || outlineRect.height <= 0) { return; } const isLeftClamped = rect.left < outlineRect.left; const isTopClamped = rect.top < outlineRect.top; const isRightClamped = Math.round(rect.left + rect.width) > Math.round(clampingRect.width); const isBottomClamped = Math.round(rect.top + rect.height) > Math.round(clampingRect.height); return ( <> {propertyStyle}
{children}
); }; ================================================ FILE: apps/builder/app/builder/features/workspace/canvas-tools/outline/selected-instance-outline.tsx ================================================ import { useStore } from "@nanostores/react"; import { $instances, $selectedInstanceOutlineAndInstance, $selectedInstanceSelector, } from "~/shared/nano-states"; import { $textEditingInstanceSelector } from "~/shared/nano-states"; import { isDescendantOrSelf } from "~/shared/tree-utils"; import { Outline } from "./outline"; import { applyScale } from "../apply-scale"; import { $clampingRect, $scale } from "~/builder/shared/nano-states"; import { findClosestSlot } from "~/shared/instance-utils"; import { $ephemeralStyles } from "~/canvas/stores"; export const SelectedInstanceOutline = () => { const instances = useStore($instances); const selectedInstanceSelector = useStore($selectedInstanceSelector); const textEditingInstanceSelector = useStore($textEditingInstanceSelector); const outline = useStore($selectedInstanceOutlineAndInstance); const scale = useStore($scale); const ephemeralStyles = useStore($ephemeralStyles); const clampingRect = useStore($clampingRect); if (selectedInstanceSelector === undefined) { return; } if (clampingRect === undefined) { return; } const isEditingCurrentInstance = textEditingInstanceSelector !== undefined && isDescendantOrSelf( selectedInstanceSelector, textEditingInstanceSelector.selector ); if ( isEditingCurrentInstance || outline === undefined || ephemeralStyles.length !== 0 ) { return; } const variant = findClosestSlot(instances, selectedInstanceSelector) ? "slot" : "default"; const rect = applyScale(outline.rect, scale); return ; }; ================================================ FILE: apps/builder/app/builder/features/workspace/canvas-tools/resize-handles.tsx ================================================ import { useStore } from "@nanostores/react"; import { findApplicableMedia } from "@webstudio-is/css-engine"; import { css, disableCanvasPointerEvents, numericScrubControl, theme, } from "@webstudio-is/design-system"; import { useEffect, useRef } from "react"; import { $canvasWidth } from "~/builder/shared/nano-states"; import { minCanvasWidth } from "~/shared/breakpoints"; import { $breakpoints, $isResizingCanvas, $selectedBreakpointId, } from "~/shared/nano-states"; const handlesContainerStyle = css({ position: "absolute", top: 0, width: 5, bottom: 0, cursor: "col-resize", pointerEvents: "auto", color: "transparent", "&::before": { position: "absolute", content: '""', inset: 0, background: "currentColor", }, "& svg": { position: "absolute", top: "50%", right: 0, transform: "translateX(100%)", color: theme.colors.foregroundSubtle, }, "&[data-align=left]": { left: 0, }, "&[data-align=right]": { right: 0, }, "&[data-state=resizing]::before": { display: "none", }, "&:hover, &:has(+ &:hover), &:hover+&": { "&::before, & svg": { color: theme.colors.backgroundPrimaryLight, }, }, // A little specificity hack to override the previou selector "&&[data-state=resizing] svg": { color: theme.colors.foregroundSubtle, }, }); const handleIcon = ( ); const updateBreakpoint = (width: number) => { const applicableBreakpoint = findApplicableMedia( Array.from($breakpoints.get().values()), width ); if (applicableBreakpoint) { $selectedBreakpointId.set(applicableBreakpoint.id); } }; const useScrub = ({ side }: { side: "right" | "left" }) => { const ref = useRef(null); useEffect(() => { if (ref.current === null) { return; } let enableCanvasPointerEvents: (() => void) | undefined; const disposeScrubControl = numericScrubControl(ref.current, { getInitialValue() { return $canvasWidth.get() ?? 0; }, getValue(state, movement) { const value = side === "left" ? // * 2 is a compensation for the fact that canvas is centered, so when we scrub, width has to change twice faster, // otherwise cursor will be faster than the edge movement state.value - movement * 2 : state.value + movement * 2; return Math.max(value, minCanvasWidth); }, onStatusChange(status) { if (status === "scrubbing") { enableCanvasPointerEvents?.(); enableCanvasPointerEvents = disableCanvasPointerEvents(); $isResizingCanvas.set(true); return; } enableCanvasPointerEvents?.(); $isResizingCanvas.set(false); }, onValueInput(event) { $canvasWidth.set(event.value); updateBreakpoint(event.value); }, }); return () => { enableCanvasPointerEvents?.(); disposeScrubControl(); }; }, [side]); return ref; }; export const ResizeHandles = () => { const isResizing = useStore($isResizingCanvas); const leftRef = useScrub({ side: "left" }); const rightRef = useScrub({ side: "right" }); const state = isResizing ? "resizing" : "idle"; return ( <>
{handleIcon}
); }; ================================================ FILE: apps/builder/app/builder/features/workspace/canvas-tools/text-toolbar.tsx ================================================ import { useRef, useEffect } from "react"; import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import { computePosition, flip, offset, shift } from "@floating-ui/dom"; import { theme, Flex, IconButton, Tooltip } from "@webstudio-is/design-system"; import { SuperscriptIcon, SubscriptIcon, XSmallIcon, BoldIcon, TextItalicIcon, LinkIcon, PaintBrushIcon, } from "@webstudio-is/icons"; import { $selectedInstanceSelector } from "~/shared/nano-states"; import { type TextToolbarState, $textToolbar } from "~/shared/nano-states"; import { $scale } from "~/builder/shared/nano-states"; import { emitCommand } from "~/builder/shared/commands"; import { $instanceTags } from "../../style-panel/shared/model"; const getRectForRelativeRect = ( parent: DOMRect, rel: DOMRect, scale: number ) => { const scaleRatio = scale / 100; return { x: parent.x + rel.x * scaleRatio, y: parent.y + rel.y * scaleRatio, width: rel.width * scaleRatio, height: rel.height * scaleRatio, top: parent.top + rel.top * scaleRatio, left: parent.left + rel.left * scaleRatio, bottom: parent.top + rel.bottom * scaleRatio, right: parent.left + rel.right * scaleRatio, }; }; const $isWithinLink = computed( [$selectedInstanceSelector, $instanceTags], (selectedInstanceSelector, instanceTags) => { if (selectedInstanceSelector === undefined) { return false; } for (const instanceId of selectedInstanceSelector) { const tag = instanceTags.get(instanceId); if (tag === "a") { return true; } } return false; } ); type ToolbarProps = { state: TextToolbarState; scale: number; }; const Toolbar = ({ state, scale }: ToolbarProps) => { const isWithinLink = useStore($isWithinLink); const rootRef = useRef(null); useEffect(() => { if (state.selectionRect === undefined) { return; } if (rootRef.current?.parentElement) { const floating = rootRef.current; const parent = rootRef.current.parentElement; const newRect = getRectForRelativeRect( parent.getBoundingClientRect(), state.selectionRect, scale ); const reference = { getBoundingClientRect: () => newRect, }; computePosition(reference, floating, { placement: "top", // offset should be first for shift and flip // to consider it while detecting overflow middleware: [offset(12), shift({ padding: 4 }), flip()], }).then(({ x, y }) => { floating.style.transform = `translate(${x}px, ${y}px)`; }); } }, [state.selectionRect, scale]); const isCleared = state.isBold === false && state.isItalic === false && state.isSuperscript === false && state.isSubscript === false && state.isLink === false && state.isSpan === false; return ( { event.stopPropagation(); }} // We use onPointerDown here to prevent the canvas from being inert (see builder.tsx for more details) onPointerDown={(event) => { // We don't want the logic in the builder to make canvas inert to be triggered event.preventDefault(); }} > emitCommand("formatClear")} > emitCommand("formatBold")} > emitCommand("formatItalic")} > emitCommand("formatSuperscript")} > emitCommand("formatSubscript")} > {isWithinLink === false && ( emitCommand("formatLink")} > )} emitCommand("formatSpan")} > ); }; export const TextToolbar = () => { const textToolbar = useStore($textToolbar); const scale = useStore($scale); const selectedInstanceSelector = useStore($selectedInstanceSelector); if ( textToolbar?.selectionRect === undefined || selectedInstanceSelector === undefined ) { return null; } return ; }; ================================================ FILE: apps/builder/app/builder/features/workspace/canvas-tools/use-subscribe-drag-drop-state.ts ================================================ import { useSubscribe } from "~/shared/pubsub"; import { $dragAndDropState } from "~/shared/nano-states"; export const useSubscribeDragAndDropState = () => { useSubscribe("dragStart", (dragPayload) => { // It's possible that dropTargetChange comes before dragStart. // So it's important to spread the current ...state here. $dragAndDropState.set({ ...$dragAndDropState.get(), isDragging: true, dragPayload, }); }); useSubscribe("dropTargetChange", (dropTarget) => { $dragAndDropState.set({ ...$dragAndDropState.get(), dropTarget }); }); useSubscribe("dragEnd", () => { $dragAndDropState.set({ isDragging: false }); }); }; ================================================ FILE: apps/builder/app/builder/features/workspace/index.ts ================================================ export * from "./workspace"; export * from "./canvas-iframe"; ================================================ FILE: apps/builder/app/builder/features/workspace/workspace.tsx ================================================ import { useEffect, useRef, type ReactNode } from "react"; import { useStore } from "@nanostores/react"; import { theme, css } from "@webstudio-is/design-system"; import { $canvasWidth, $scale, $workspaceRect, } from "~/builder/shared/nano-states"; import { $textEditingInstanceSelector } from "~/shared/nano-states"; import { CanvasTools } from "./canvas-tools"; import { selectInstance } from "~/shared/awareness"; import { ResizeHandles } from "./canvas-tools/resize-handles"; import { MediaBadge } from "./canvas-tools/media-badge"; import { useSetCanvasWidth } from "~/builder/shared/calc-canvas-width"; const workspaceStyle = css({ flexGrow: 1, background: theme.colors.backgroundCanvas, position: "relative", // Prevent scrollIntoView from scrolling the whole page // Commented to see what it will break // overflow: "clip", }); const canvasContainerStyle = css({ position: "absolute", transformOrigin: "0 0", // We had a case where some Windows 10 + Chrome 129 users couldn't scroll iframe canvas. willChange: "transform", }); const useMeasureWorkspace = () => { const ref = useRef(null); useEffect(() => { const element = ref.current; if (element === null) { return; } const observer = new ResizeObserver((entries) => { $workspaceRect.set(entries[0].contentRect); }); observer.observe(element); return () => { observer.disconnect(); }; }, []); return ref; }; const getCanvasStyle = ( scale: number, workspaceRect?: DOMRect, canvasWidth?: number ) => { let canvasHeight; // For some reason scale is 0 in chrome dev tools mobile touch simulated vervsion. if (workspaceRect?.height && scale !== 0) { canvasHeight = workspaceRect.height / (scale / 100); } return { width: canvasWidth ?? "100%", height: canvasHeight ?? "100%", left: "50%", transform: `scale(${scale}%) translateX(-50%)`, }; }; const useCanvasStyle = () => { const scale = useStore($scale); const workspaceRect = useStore($workspaceRect); const canvasWidth = useStore($canvasWidth); return getCanvasStyle(scale, workspaceRect, canvasWidth); }; const useOutlineStyle = () => { const scale = useStore($scale); const workspaceRect = useStore($workspaceRect); const canvasWidth = useStore($canvasWidth); const style = getCanvasStyle(100, workspaceRect, canvasWidth); return { ...style, width: canvasWidth === undefined ? "100%" : (canvasWidth ?? 0) * (scale / 100), } as const; }; type WorkspaceProps = { children: ReactNode; }; export const Workspace = ({ children }: WorkspaceProps) => { const canvasStyle = useCanvasStyle(); const workspaceRef = useMeasureWorkspace(); useSetCanvasWidth(); const handleWorkspaceClick = () => { selectInstance(undefined); $textEditingInstanceSelector.set(undefined); }; const outlineStyle = useOutlineStyle(); return ( <>
{children}
); }; export const CanvasToolsContainer = () => { const outlineStyle = useOutlineStyle(); return (
); }; ================================================ FILE: apps/builder/app/builder/index.client.ts ================================================ export * from "./builder"; ================================================ FILE: apps/builder/app/builder/inspector.tsx ================================================ import { useRef } from "react"; import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import type { Instance } from "@webstudio-is/sdk"; import { theme, PanelTabs, PanelTabsList, PanelTabsTrigger, PanelTabsContent, Card, Text, EnhancedTooltipProvider, Flex, ScrollArea, Separator, Tooltip, Kbd, } from "@webstudio-is/design-system"; import { ModeMenu, StylePanel } from "~/builder/features/style-panel"; import { SettingsPanel } from "~/builder/features/settings-panel"; import { $registeredComponentMetas, $dragAndDropState, $isDesignMode, } from "~/shared/nano-states"; import { NavigatorTree } from "~/builder/features/navigator"; import type { Settings } from "~/builder/shared/client-settings"; import { $activeInspectorPanel } from "~/builder/shared/nano-states"; import { $selectedInstance, $selectedInstanceKey, $selectedPage, } from "~/shared/awareness"; import { InstanceIcon, getInstanceLabel } from "./shared/instance-label"; const InstanceInfo = ({ instance }: { instance: Instance }) => { return ( {getInstanceLabel(instance)} ); }; type InspectorProps = { navigatorLayout: Settings["navigatorLayout"]; }; const contentStyle = { display: "flex", flexDirection: "column", overflow: "auto", }; const $isDragging = computed([$dragAndDropState], (state) => state.isDragging); export const Inspector = ({ navigatorLayout }: InspectorProps) => { const selectedInstance = useStore($selectedInstance); const selectedInstanceKey = useStore($selectedInstanceKey); const tabsRef = useRef(null); const isDragging = useStore($isDragging); const metas = useStore($registeredComponentMetas); const selectedPage = useStore($selectedPage); const activeInspectorPanel = useStore($activeInspectorPanel); const isDesignMode = useStore($isDesignMode); if (navigatorLayout === "docked" && isDragging) { return ; } if (selectedInstance === undefined || selectedInstanceKey === undefined) { return ( {/* @todo: use this space for something more usefull: a-la figma's no instance selected sate, maybe create an issue with a more specific proposal? */} Select an instance on the canvas ); } const meta = metas.get(selectedInstance.component); const documentType = selectedPage?.meta.documentType ?? "html"; type PanelName = "style" | "settings"; const availablePanels = new Set(); availablePanels.add("settings"); if ( // forbid styling body in xml document documentType === "html" && // forbid styling components without preset meta?.presetStyle !== undefined && isDesignMode ) { availablePanels.add("style"); } return ( { $activeInspectorPanel.set(panel as PanelName); }} asChild > {availablePanels.has("style") && ( CSS for the selected instance   } >
Style
)} {availablePanels.has("settings") && ( Settings, properties and attributes of the selected instance   } >
Settings
)}
); }; ================================================ FILE: apps/builder/app/builder/shared/asset-manager/asset-filters.tsx ================================================ import { useMemo } from "react"; import { Select } from "@webstudio-is/design-system"; import type { AllowedFileExtension } from "@webstudio-is/sdk"; const CATEGORY_ALL = "All" as const; /** * Display categories for file types in the UI * These match the comment sections in ALLOWED_FILE_TYPES */ const DISPLAY_CATEGORIES = [ CATEGORY_ALL, "Images", "Documents", "Video", "Audio", "Code", "Archives", "Fonts", ] as const; type DisplayCategory = (typeof DISPLAY_CATEGORIES)[number]; /** * Type-safe mapping of file extensions to display categories. * * TypeScript enforces that: * 1. All keys must be valid extensions from ALLOWED_FILE_TYPES * 2. All extensions from ALLOWED_FILE_TYPES must be included (completeness check) * 3. All values must be valid DisplayCategory types * * When adding a new file extension to ALLOWED_FILE_TYPES, TypeScript will * produce a compile error until that extension is also added to this mapping. */ const EXTENSION_TO_DISPLAY_CATEGORY: Record< AllowedFileExtension, DisplayCategory > = { // Documents pdf: "Documents", doc: "Documents", docx: "Documents", xls: "Documents", xlsx: "Documents", csv: "Documents", ppt: "Documents", pptx: "Documents", // Code txt: "Code", md: "Code", js: "Code", css: "Code", json: "Code", html: "Code", xml: "Code", // Archives zip: "Archives", rar: "Archives", // Audio mp3: "Audio", wav: "Audio", ogg: "Audio", m4a: "Audio", // Video mp4: "Video", mov: "Video", avi: "Video", webm: "Video", // Images jpg: "Images", jpeg: "Images", png: "Images", gif: "Images", svg: "Images", webp: "Images", avif: "Images", ico: "Images", bmp: "Images", tif: "Images", tiff: "Images", // Fonts woff: "Fonts", woff2: "Fonts", ttf: "Fonts", otf: "Fonts", }; /** * Get array of extensions for a given display category */ const getExtensionsForCategory = ( category: DisplayCategory ): AllowedFileExtension[] | "*" => { if (category === CATEGORY_ALL) { return "*"; } return Object.entries(EXTENSION_TO_DISPLAY_CATEGORY) .filter(([, cat]) => cat === category) .map(([ext]) => ext as AllowedFileExtension); }; type AssetFiltersProps = { formatCounts: Partial>; value: AllowedFileExtension[] | "*"; onChange: (extensions: AllowedFileExtension[] | "*") => void; }; export const AssetFilters = ({ formatCounts, value, onChange, }: AssetFiltersProps) => { // Aggregate extension counts by display category const categoryCounts = useMemo(() => { const counts: Record = { [CATEGORY_ALL]: 0, Images: 0, Documents: 0, Video: 0, Audio: 0, Code: 0, Archives: 0, Fonts: 0, }; Object.entries(formatCounts).forEach(([ext, count]) => { const category = EXTENSION_TO_DISPLAY_CATEGORY[ext as AllowedFileExtension]; if (category && count !== undefined) { counts[category] += count; counts[CATEGORY_ALL] += count; } }); return counts; }, [formatCounts]); // Compute display category from extensions array const selectedCategory: DisplayCategory = value === "*" ? CATEGORY_ALL : (EXTENSION_TO_DISPLAY_CATEGORY[value[0]] as | DisplayCategory | undefined) || CATEGORY_ALL; const options = DISPLAY_CATEGORIES.map((category) => ({ label: `${category} (${categoryCounts[category] || 0})`, value: category, })); const selectedOption = options.find((opt) => opt.value === selectedCategory); return ( ); }; export const AssetUpload = ({ type, accept }: AssetUploadProps) => { const authPermit = useStore($authPermit); if (authPermit !== "view") { // Split into a separate component to avoid using `useUpload` hook unnecessarily // (It's hard to mock this hook in storybook) return ; } return ( ); }; ================================================ FILE: apps/builder/app/builder/shared/assets/asset-utils.test.ts ================================================ import { expect, test, describe } from "vitest"; import { parseAssetName, formatAssetName, getImageNameAndType, getSha256Hash, detectAssetType, uploadingFileDataToAsset, } from "./asset-utils"; import type { Asset } from "@webstudio-is/sdk"; describe("parseAssetName", () => { test("parses name with hash and extension", () => { expect(parseAssetName("hello_hash.ext")).toEqual({ basename: "hello", hash: "hash", ext: "ext", }); }); test("parses name without hash", () => { expect(parseAssetName("hello.ext")).toEqual({ basename: "hello", hash: "", ext: "ext", }); }); test("parses name with multiple underscores", () => { expect(parseAssetName("hello_hash1.ext_hash2")).toEqual({ basename: "hello", hash: "hash1", ext: "ext_hash2", }); }); test("parses name with hash but no extension", () => { expect(parseAssetName("hello_hash1_hash2")).toEqual({ basename: "hello_hash1", hash: "hash2", ext: "", }); }); }); describe("formatAssetName", () => { test("formats asset with filename", () => { const asset: Pick = { name: "uploaded_abc123.jpg", filename: "myimage", }; expect(formatAssetName(asset)).toBe("myimage.jpg"); }); test("formats asset without filename", () => { const asset: Pick = { name: "uploaded_abc123.jpg", filename: undefined, }; expect(formatAssetName(asset)).toBe("uploaded.jpg"); }); test("formats asset with no extension", () => { const asset: Pick = { name: "uploaded_abc123", filename: "document", }; expect(formatAssetName(asset)).toBe("document."); }); }); describe("getImageNameAndType", () => { test("returns MIME type and filename for valid image", () => { const result = getImageNameAndType("photo.jpg"); expect(result).toEqual(["image/jpeg", "photo.jpg"]); }); test("handles different image extensions", () => { expect(getImageNameAndType("image.png")).toEqual([ "image/png", "image.png", ]); expect(getImageNameAndType("graphic.gif")).toEqual([ "image/gif", "graphic.gif", ]); expect(getImageNameAndType("vector.svg")).toEqual([ "image/svg+xml", "vector.svg", ]); }); test("is case-insensitive", () => { const result = getImageNameAndType("PHOTO.JPG"); expect(result).toEqual(["image/jpeg", "PHOTO.JPG"]); }); test("returns undefined for non-image files", () => { expect(getImageNameAndType("document.pdf")).toBeUndefined(); expect(getImageNameAndType("video.mp4")).toBeUndefined(); expect(getImageNameAndType("audio.mp3")).toBeUndefined(); }); test("returns undefined for files without extension", () => { expect(getImageNameAndType("filename")).toBeUndefined(); }); test("handles files with multiple dots", () => { const result = getImageNameAndType("my.photo.file.png"); expect(result).toEqual(["image/png", "my.photo.file.png"]); }); }); describe("getSha256Hash", () => { test("generates consistent hash for same input", async () => { const hash1 = await getSha256Hash("test string"); const hash2 = await getSha256Hash("test string"); expect(hash1).toBe(hash2); }); test("generates different hashes for different inputs", async () => { const hash1 = await getSha256Hash("string1"); const hash2 = await getSha256Hash("string2"); expect(hash1).not.toBe(hash2); }); test("generates 64 character hex string", async () => { const hash = await getSha256Hash("test"); expect(hash).toMatch(/^[0-9a-f]{64}$/); }); test("handles empty string", async () => { const hash = await getSha256Hash(""); expect(hash).toMatch(/^[0-9a-f]{64}$/); }); test("generates expected hash for known input", async () => { // SHA-256 of "hello" is known const hash = await getSha256Hash("hello"); expect(hash).toBe( "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ); }); }); describe("detectAssetType", () => { test("detects image files", () => { expect(detectAssetType("photo.jpg")).toBe("image"); expect(detectAssetType("image.png")).toBe("image"); expect(detectAssetType("graphic.gif")).toBe("image"); expect(detectAssetType("vector.svg")).toBe("image"); expect(detectAssetType("picture.webp")).toBe("image"); }); test("detects font files", () => { expect(detectAssetType("font.woff")).toBe("font"); expect(detectAssetType("font.woff2")).toBe("font"); expect(detectAssetType("font.ttf")).toBe("font"); expect(detectAssetType("font.otf")).toBe("font"); }); test("detects video files", () => { expect(detectAssetType("video.mp4")).toBe("video"); expect(detectAssetType("video.webm")).toBe("video"); expect(detectAssetType("video.mov")).toBe("video"); expect(detectAssetType("video.avi")).toBe("video"); }); test("returns file for other types", () => { expect(detectAssetType("document.pdf")).toBe("file"); expect(detectAssetType("audio.mp3")).toBe("file"); expect(detectAssetType("data.json")).toBe("file"); expect(detectAssetType("doc.docx")).toBe("file"); }); test("is case-insensitive", () => { expect(detectAssetType("PHOTO.JPG")).toBe("image"); expect(detectAssetType("FONT.WOFF2")).toBe("font"); expect(detectAssetType("VIDEO.MP4")).toBe("video"); expect(detectAssetType("DOC.PDF")).toBe("file"); }); test("handles files without extension", () => { expect(detectAssetType("filename")).toBe("file"); }); test("handles files with multiple dots", () => { expect(detectAssetType("my.photo.file.png")).toBe("image"); expect(detectAssetType("my.font.file.woff2")).toBe("font"); expect(detectAssetType("my.video.file.mp4")).toBe("video"); expect(detectAssetType("my.doc.file.pdf")).toBe("file"); }); }); describe("uploadingFileDataToAsset", () => { test("extracts format from MIME type for font with valid MIME", () => { const file = new File(["content"], "InterVariable.woff2", { type: "font/woff2", }); const result = uploadingFileDataToAsset({ source: "file", file, assetId: "test-id", type: "font", objectURL: "blob:test", }); expect(result).toMatchObject({ id: "test-id", name: "InterVariable.woff2", format: "woff2", type: "font", }); }); test("falls back to filename extension when MIME type is missing", () => { const file = new File(["content"], "InterVariable.woff2", { type: "", // Empty MIME type }); const result = uploadingFileDataToAsset({ source: "file", file, assetId: "test-id", type: "font", objectURL: "blob:test", }); expect(result).toMatchObject({ id: "test-id", name: "InterVariable.woff2", format: "woff2", type: "font", }); }); test("handles image files with valid MIME type", () => { const file = new File(["content"], "photo.jpg", { type: "image/jpeg", }); const result = uploadingFileDataToAsset({ source: "file", file, assetId: "test-id", type: "image", objectURL: "blob:test", }); expect(result).toMatchObject({ id: "test-id", name: "photo.jpg", format: "jpeg", type: "image", }); }); test("handles video files", () => { const file = new File(["content"], "video.mp4", { type: "video/mp4", }); const result = uploadingFileDataToAsset({ source: "file", file, assetId: "test-id", type: "video", objectURL: "blob:test", }); expect(result).toMatchObject({ id: "test-id", name: "video.mp4", format: "mp4", type: "file", }); }); test("handles generic file types", () => { const file = new File(["content"], "document.pdf", { type: "application/pdf", }); const result = uploadingFileDataToAsset({ source: "file", file, assetId: "test-id", type: "file", objectURL: "blob:test", }); expect(result).toMatchObject({ id: "test-id", name: "document.pdf", format: "pdf", type: "file", }); }); test("extracts format from filename when MIME type has no subtype", () => { const file = new File(["content"], "font.ttf", { type: "font", }); const result = uploadingFileDataToAsset({ source: "file", file, assetId: "test-id", type: "font", objectURL: "blob:test", }); expect(result).toMatchObject({ id: "test-id", name: "font.ttf", format: "ttf", type: "font", }); }); test("handles files with no extension", () => { const file = new File(["content"], "README", { type: "", }); const result = uploadingFileDataToAsset({ source: "file", file, assetId: "test-id", type: "file", objectURL: "blob:test", }); expect(result).toMatchObject({ id: "test-id", name: "README", format: "", type: "file", }); }); }); ================================================ FILE: apps/builder/app/builder/shared/assets/asset-utils.ts ================================================ import type { Asset, FontAsset, ImageAsset, AllowedFileExtension, } from "@webstudio-is/sdk"; import { nanoid } from "nanoid"; import { getMimeTypeByExtension, IMAGE_EXTENSIONS, detectAssetType, getAssetUrl, } from "@webstudio-is/sdk"; import type { UploadingFileData } from "~/shared/nano-states"; export { detectAssetType, getAssetUrl }; export const getImageNameAndType = (fileName: string) => { // Extract extension from filename const extractedExt = fileName.split(".").pop()?.toLowerCase(); if (!extractedExt) { return; } // Check if it's a valid image extension if (!IMAGE_EXTENSIONS.includes(extractedExt as AllowedFileExtension)) { return; } return [getMimeTypeByExtension(extractedExt)!, fileName] as const; }; const extractImageNameAndMimeTypeFromUrl = (url: URL) => { const nameFromPath = url.pathname .split("/") .map(getImageNameAndType) .filter(Boolean)[0]; if (nameFromPath != null) { return nameFromPath; } const nameFromSearchParams = [...url.searchParams.values()] .map(getImageNameAndType) .filter(Boolean)[0]; if (nameFromSearchParams != null) { return nameFromSearchParams; } // Any image format is suitable const FALLBACK_URL_TYPE = "image/png"; return [FALLBACK_URL_TYPE, `${nanoid()}.png`] as const; }; const bufferToHex = (buffer: ArrayBuffer) => { const byteArray = new Uint8Array(buffer); return Array.from(byteArray, (byte) => byte.toString(16).padStart(2, "0") ).join(""); }; export const getSha256Hash = async (data: string) => { const encoder = new TextEncoder(); const dataBuffer = encoder.encode(data); const hashBuffer = await crypto.subtle.digest("SHA-256", dataBuffer); return bufferToHex(hashBuffer); }; const readFileAsArrayBuffer = (file: File): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as ArrayBuffer); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(file); }); export const getSha256HashOfFile = async (file: File) => { const arrayBuffer = await readFileAsArrayBuffer(file); const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); return bufferToHex(hashBuffer); }; export const getMimeType = (file: File | URL) => { if (file instanceof File) { return file.type; } return extractImageNameAndMimeTypeFromUrl(file)[0]; }; export const getFileName = (file: File | URL) => { if (file instanceof File) { return file.name; } return extractImageNameAndMimeTypeFromUrl(file)[1]; }; export const uploadingFileDataToAsset = ( fileData: UploadingFileData ): Asset => { const fileOrUrl = fileData.source === "file" ? fileData.file : new URL(fileData.url); const fileName = getFileName(fileOrUrl); const mimeType = getMimeType(fileOrUrl); // Extract format from MIME type if available, otherwise from filename extension let format = mimeType.split("/")[1]; if (!format) { // Fallback to file extension if MIME type doesn't provide format const match = fileName.match(/\.([^.]+)$/); format = match ? match[1].toLowerCase() : ""; } const assetType = detectAssetType(fileName); if (assetType === "video") { // Videos should be file type, not image type const asset: Asset = { id: fileData.assetId, name: fileName, format, type: "file", description: "", createdAt: "", projectId: "", size: 0, meta: {}, }; return asset; } if (assetType === "image") { const asset: ImageAsset = { id: fileData.assetId, name: fileName, format, type: "image", description: "", createdAt: "", projectId: "", size: 0, meta: { width: Number.NaN, height: Number.NaN, }, }; return asset; } if (assetType === "font") { const asset: FontAsset = { id: fileData.assetId, name: fileName, format: format as FontAsset["format"], type: "font", description: "", createdAt: "", projectId: "", size: 0, meta: { family: "system", style: "normal", weight: 400, }, }; return asset; } // Default to file type for all other types (documents, code, audio, etc.) const asset: Asset = { id: fileData.assetId, name: fileName, format, type: "file", description: "", createdAt: "", projectId: "", size: 0, meta: {}, }; return asset; }; type ParsedAssetName = { basename: string; hash: string; ext: string; }; export const parseAssetName = (name: string): ParsedAssetName => { let hash = ""; let ext = ""; const lastDotAt = name.lastIndexOf("."); if (lastDotAt > -1) { ext = name.slice(lastDotAt + 1); name = name.slice(0, lastDotAt); } const lastUnderscoreAt = name.lastIndexOf("_"); if (lastUnderscoreAt > -1) { hash = name.slice(lastUnderscoreAt + 1); name = name.slice(0, lastUnderscoreAt); } return { basename: name, hash, ext }; }; export const formatAssetName = (asset: Pick) => { const { basename, ext } = parseAssetName(asset.name); const formattedName = `${asset.filename ?? basename}.${ext}`; return formattedName; }; ================================================ FILE: apps/builder/app/builder/shared/assets/assets-shell.tsx ================================================ import { useEffect, useRef, useState, type ComponentProps, type JSX, } from "react"; import type { AssetType } from "@webstudio-is/asset-uploader"; import { Flex, ScrollArea, SearchField, Text, theme, } from "@webstudio-is/design-system"; import { acceptUploadType, validateFiles } from "./asset-upload"; import { detectAssetType } from "@webstudio-is/sdk"; import { NotFound } from "./not-found"; import { Separator } from "./separator"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { containsFiles, getFiles, } from "@atlaskit/pragmatic-drag-and-drop/external/file"; import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import invariant from "tiny-invariant"; import type { ContainsSource } from "@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/external/native-types"; import { uploadAssets } from "./upload-assets"; import { UploadIcon } from "@webstudio-is/icons"; import { IDLE, isBlockedByBackdrop, registerDrop, useExternalDragStateEffect, type ExternalMonitorDragState, } from "./drag-monitor"; type AssetsShellProps = { filters?: JSX.Element; searchProps: ComponentProps; children: JSX.Element; type: AssetType; accept?: string; isEmpty: boolean; }; const containsFilesOrUri = (parameter: ContainsSource) => { return ( containsFiles(parameter) || parameter.source.types.includes("text/uri-list") ); }; const OVER = 2; type DropTargetState = typeof IDLE | typeof OVER; export const AssetsShell = ({ filters, searchProps, isEmpty, children, type, accept, }: AssetsShellProps) => { const ref = useRef(null); const [monitorState, setMonitorState] = useState(IDLE); const [dropTargetState, setDropTargetState] = useState(IDLE); useExternalDragStateEffect((state) => { const element = ref.current; if (element == null) { return; } if (state === IDLE) { setMonitorState(IDLE); return; } if (isBlockedByBackdrop(element)) { setMonitorState(IDLE); return; } setMonitorState(state); }); /** * Allow URL drop for images only */ const containsByType = type === "image" ? containsFilesOrUri : containsFiles; useEffect(() => { const element = ref.current; invariant(element); // Do not react if any dialog is opened above const isBlockedByBackdropCallback = ( blocked: typeof containsByType, notBlocked: typeof containsByType ) => { return (parameter: ContainsSource) => { // Check if this element is the original element or its descendant return isBlockedByBackdrop(element) ? blocked(parameter) : notBlocked(parameter); }; }; return combine( dropTargetForExternal({ element: element, canDrop: isBlockedByBackdropCallback(() => false, containsByType), onDragEnter: () => setDropTargetState(OVER), onDragLeave: () => setDropTargetState(IDLE), onDrop: async ({ source }) => { registerDrop(); setMonitorState(IDLE); setDropTargetState(IDLE); const droppedUrls = await Promise.all( source.items .filter((item) => item.type === "text/uri-list") .map( (item) => new Promise((resolve) => item.getAsString((str) => resolve(new URL(str))) ) ) ); const droppedFiles = validateFiles(getFiles({ source })); const files = droppedFiles .filter((file) => file != null) .filter((file) => { if (acceptUploadType(type, accept, file)) { return true; } console.warn( `Unsupported file dropped for type=${type}, accept=${accept} and file.type=${file.type}, file.name=${file.name}` ); return false; }); // Group files by their detected type const filesByType = new Map(); for (const file of files) { const detectedType = detectAssetType(file.name); if (!filesByType.has(detectedType)) { filesByType.set(detectedType, []); } filesByType.get(detectedType)!.push(file); } // Upload each group with the correct type for (const [detectedType, filesOfType] of filesByType) { uploadAssets(detectedType as AssetType, filesOfType); } uploadAssets(type, droppedUrls); }, }) ); }, [accept, containsByType, type]); const dragState = Math.max(monitorState, dropTargetState); return ( {filters} {isEmpty && } {children} Drop files here ); }; ================================================ FILE: apps/builder/app/builder/shared/assets/delete-assets.ts ================================================ import type { Asset } from "@webstudio-is/sdk"; import { $assets } from "~/shared/nano-states"; import { serverSyncStore } from "~/shared/sync/sync-stores"; import { onNextTransactionComplete } from "~/shared/sync/project-queue"; import { invalidateAssets } from "~/shared/resources"; export const deleteAssets = (assetIds: Asset["id"][]) => { serverSyncStore.createTransaction([$assets], (assets) => { for (const assetId of assetIds) { assets.delete(assetId); } }); // Wait for server to confirm transaction, then invalidate cache onNextTransactionComplete(() => { invalidateAssets(); }); }; ================================================ FILE: apps/builder/app/builder/shared/assets/drag-monitor.tsx ================================================ import { atom, computed } from "nanostores"; import { useEffect, useState } from "react"; import { monitorForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { preventUnhandled } from "@atlaskit/pragmatic-drag-and-drop/prevent-unhandled"; import { containsFiles } from "@atlaskit/pragmatic-drag-and-drop/external/file"; import type { ContainsSource } from "@atlaskit/pragmatic-drag-and-drop/dist/types/public-utils/external/native-types"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { canvasApi } from "~/shared/canvas-api"; import { useDebouncedCallback } from "use-debounce"; import { $canvasIframeState } from "~/shared/nano-states"; import invariant from "tiny-invariant"; import { getAllElementsBoundingBox } from "~/shared/dom-utils"; import { useEffectEvent } from "~/shared/hook-utils/effect-event"; export const IDLE = 0; export const POTENTIAL = 1; export type ExternalMonitorDragState = typeof IDLE | typeof POTENTIAL; const $monitorDragState = atom(IDLE); const $monitorCanvasDragState = atom(IDLE); const $externalDragState = computed( [$monitorDragState, $monitorCanvasDragState], (monitorDragState, monitorCanvasDragState) => { return Math.max( monitorDragState, monitorCanvasDragState ) as ExternalMonitorDragState; } ); const containsFilesOrUri = (parameter: ContainsSource) => { return ( containsFiles(parameter) || parameter.source.types.includes("text/uri-list") ); }; let usageCounter = 0; export const ExternalDragDropMonitor = () => { const [refresh, setRefresh] = useState(0); const handleBuilderOnDrop = useDebouncedCallback(() => { $monitorDragState.set(IDLE); }, 300); const handleCanvasOnDrop = useDebouncedCallback(() => { $monitorCanvasDragState.set(IDLE); }, 300); const preventUnhandledStop = useDebouncedCallback(() => { preventUnhandled.stop(); canvasApi.preventUnhandled.stop(); }, 300); useEffect(() => { usageCounter += 1; invariant(usageCounter === 1, "Monitor can be used only once per app"); return () => { usageCounter -= 1; }; }, []); useEffect(() => { if (false === canvasApi.isInitialized()) { return $canvasIframeState.listen((state) => { if (state === "ready") { setRefresh((prev) => prev + 1); } }); } const preventUnhandledStart = () => { preventUnhandled.start(); canvasApi.preventUnhandled.start(); }; return combine( monitorForExternal({ canMonitor: containsFilesOrUri, onDragStart: () => { preventUnhandledStart(); $monitorDragState.set(POTENTIAL); handleBuilderOnDrop.cancel(); preventUnhandledStop.cancel(); }, onDrop: () => { handleBuilderOnDrop(); preventUnhandledStop(); }, }), canvasApi.monitorForExternal({ canMonitor: containsFilesOrUri, onDragStart: () => { preventUnhandledStart(); $monitorCanvasDragState.set(POTENTIAL); handleCanvasOnDrop.cancel(); preventUnhandledStop.cancel(); }, onDrop: () => { handleCanvasOnDrop(); preventUnhandledStop(); }, }), () => { return () => { handleBuilderOnDrop.cancel(); preventUnhandledStop.cancel(); preventUnhandled.stop(); canvasApi.preventUnhandled.stop(); $monitorDragState.set(IDLE); $monitorCanvasDragState.set(IDLE); }; } ); }, [handleBuilderOnDrop, handleCanvasOnDrop, preventUnhandledStop, refresh]); return null; }; export const useExternalDragStateEffect = ( callback: (state: ExternalMonitorDragState) => void ) => { const handleCallback = useEffectEvent(callback); useEffect(() => { return $externalDragState.subscribe(handleCallback); }, []); }; const dropCount = atom(0); export const registerDrop = () => { dropCount.set(dropCount.get() + 1); }; export const useOnDropEffect = (callback: () => void) => { const handleCallback = useEffectEvent(callback); useEffect(() => { return dropCount.listen(handleCallback); }, []); }; export const isBlockedByBackdrop = (element: Element) => { const elementRect = getAllElementsBoundingBox([element]); const centerX = elementRect.left + elementRect.width / 2; const centerY = elementRect.top + elementRect.height / 2; // Get the element directly under the center of the target element const topElement = document.elementFromPoint(centerX, centerY); const isNotBlocked = element.contains(topElement) || topElement === element; return false === isNotBlocked; }; ================================================ FILE: apps/builder/app/builder/shared/assets/index.ts ================================================ export { useAssets } from "./use-assets"; export * from "./delete-assets"; export { uploadAssets } from "./upload-assets"; export * from "./types"; export * from "./separator"; export * from "./assets-shell"; export * from "./asset-upload"; ================================================ FILE: apps/builder/app/builder/shared/assets/not-found.tsx ================================================ import { Flex, Text } from "@webstudio-is/design-system"; export const NotFound = () => { return ( No matching assets ); }; ================================================ FILE: apps/builder/app/builder/shared/assets/separator.tsx ================================================ import { Separator as SeparatorPrimitive, styled, } from "@webstudio-is/design-system"; import { theme } from "@webstudio-is/design-system"; export const Separator = styled(SeparatorPrimitive, { marginTop: theme.spacing[3], marginBottom: theme.spacing[5], }); ================================================ FILE: apps/builder/app/builder/shared/assets/types.ts ================================================ import type { Asset } from "@webstudio-is/sdk"; type PreviewAsset = Pick< Asset, "name" | "filename" | "id" | "format" | "description" | "type" >; export type UploadedAssetContainer = { status: "uploaded"; asset: Asset; }; export type UploadingAssetContainer = { status: "uploading"; objectURL: string; asset: PreviewAsset; }; /** * Assets that can be shown in the UI */ export type AssetContainer = UploadedAssetContainer | UploadingAssetContainer; export type AssetActionResponse = { uploadedAssets?: Array; deletedAssets?: Array; errors?: string; }; ================================================ FILE: apps/builder/app/builder/shared/assets/upload-assets.test.ts ================================================ import { describe, test, expect } from "vitest"; import { __testing__ } from "./upload-assets"; const { deduplicateAssetName } = __testing__; describe("upload-assets", () => { describe("deduplicateAssetName", () => { test("returns original name when no duplicates exist", () => { const existingNames = new Set(["other-file.png", "another-file.jpg"]); const result = deduplicateAssetName("unique-file.png", existingNames); expect(result).toBe("unique-file.png"); }); test("adds suffix when duplicate exists", () => { const existingNames = new Set(["duplicate.png"]); const result = deduplicateAssetName("duplicate.png", existingNames); expect(result).toBe("duplicate_1.png"); }); test("increments suffix for multiple duplicates", () => { const existingNames = new Set(["file.png", "file_1.png", "file_2.png"]); const result = deduplicateAssetName("file.png", existingNames); expect(result).toBe("file_3.png"); }); test("handles names without extension", () => { const existingNames = new Set(); const result = deduplicateAssetName("no-extension", existingNames); expect(result).toBe("no-extension"); }); test("handles empty existing names set", () => { const existingNames = new Set(); const result = deduplicateAssetName("file.jpg", existingNames); expect(result).toBe("file.jpg"); }); test("handles complex file extensions", () => { const existingNames = new Set(["archive.tar.gz"]); const result = deduplicateAssetName("archive.tar.gz", existingNames); expect(result).toBe("archive.tar_1.gz"); }); test("finds first available suffix with gaps", () => { const existingNames = new Set(["file.png", "file_2.png", "file_3.png"]); const result = deduplicateAssetName("file.png", existingNames); expect(result).toBe("file_1.png"); }); }); }); ================================================ FILE: apps/builder/app/builder/shared/assets/upload-assets.tsx ================================================ import warnOnce from "warn-once"; import invariant from "tiny-invariant"; import type { Asset } from "@webstudio-is/sdk"; import type { AssetType } from "@webstudio-is/asset-uploader"; import { Box, toast, css, theme } from "@webstudio-is/design-system"; import { sanitizeS3Key } from "@webstudio-is/asset-uploader"; import { Image, wsImageLoader } from "@webstudio-is/image"; import { restAssetsUploadPath, restAssetsPath } from "~/shared/router-utils"; import { fetch } from "~/shared/fetch.client"; import type { AssetActionResponse } from "~/builder/shared/assets"; import { $assets, $authToken, $project, $uploadingFilesDataStore, type UploadingFileData, } from "~/shared/nano-states"; import { serverSyncStore } from "~/shared/sync/sync-stores"; import { onNextTransactionComplete } from "~/shared/sync/project-queue"; import { invalidateAssets } from "~/shared/resources"; import { formatAssetName, getFileName, getMimeType, getSha256Hash, getSha256HashOfFile, } from "./asset-utils"; const safeDeleteAssets = (assetIds: Asset["id"][], projectId: string) => { const currentProjectId = $project.get()?.id; if (currentProjectId !== projectId) { toast.error("Project has been changed, files will not be uploaded"); // Can cause data corruption return; } serverSyncStore.createTransaction([$assets], (assets) => { for (const assetId of assetIds) { assets.delete(assetId); } }); onNextTransactionComplete(() => { invalidateAssets(); }); }; const safeSetAsset = (asset: Asset, projectId: string) => { const currentProjectId = $project.get()?.id; if (currentProjectId !== projectId) { toast.error("Project has been changed, files will not be uploaded"); // Can cause data corrupiton return; } serverSyncStore.createTransaction([$assets], (assets) => { assets.set(asset.id, asset); }); onNextTransactionComplete(() => { invalidateAssets(); }); }; const getFilesData = async ( type: AssetType, filesOrUrls: T[] ): Promise => { const filesData: UploadingFileData[] = []; for (const fileOrUrl of filesOrUrls) { if (fileOrUrl instanceof File) { const assetId = await getSha256HashOfFile(fileOrUrl); filesData.push({ source: "file" as const, assetId: assetId, type, file: fileOrUrl, objectURL: URL.createObjectURL(fileOrUrl), }); continue; } const assetId = await getSha256Hash(fileOrUrl.href); filesData.push({ source: "url" as const, assetId, type, url: fileOrUrl.href, objectURL: fileOrUrl.href, }); } return filesData; }; const addUploadingFilesData = (filesData: UploadingFileData[]) => { const uploadingFilesData = $uploadingFilesDataStore.get(); $uploadingFilesDataStore.set([...uploadingFilesData, ...filesData]); }; const deleteUploadingFileData = (id: UploadingFileData["assetId"]) => { const uploadingFilesData = $uploadingFilesDataStore.get(); $uploadingFilesDataStore.set( uploadingFilesData.filter((fileData) => fileData.assetId !== id) ); }; const getVideoDimensions = async (file: File) => { return new Promise<{ width: number; height: number }>((resolve, reject) => { const url = URL.createObjectURL(file); const vid = document.createElement("video"); vid.preload = "metadata"; vid.src = url; vid.onloadedmetadata = () => { URL.revokeObjectURL(url); resolve({ width: vid.videoWidth, height: vid.videoHeight }); }; vid.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Invalid video file")); }; }); }; const deduplicateAssetName = (name: string, existingNames: Set) => { // eslint-disable-next-line no-constant-condition for (let index = 0; true; index += 1) { const suffix = index === 0 ? "" : `_${index}`; const lastDotAt = name.lastIndexOf("."); if (lastDotAt === -1) { return name; } const basename = name.slice(0, lastDotAt); const ext = name.slice(lastDotAt); const nameWithSuffix = basename + suffix + ext; if (!existingNames.has(nameWithSuffix)) { return nameWithSuffix; } } }; const uploadAsset = async ({ authToken, projectId, fileOrUrl, assetType, onCompleted, onError, }: { authToken: undefined | string; projectId: string; fileOrUrl: File | URL; assetType: AssetType; onCompleted: (data: AssetActionResponse) => void; onError: (error: string) => void; }) => { try { const mimeType = getMimeType(fileOrUrl); const fileName = getFileName(fileOrUrl); const metaFormData = new FormData(); metaFormData.append("projectId", projectId); metaFormData.append("type", assetType); // sanitizeS3Key here is just because of https://github.com/remix-run/remix/issues/4443 // should be removed after fix const existingNames = new Set(); for (const asset of $assets.get().values()) { existingNames.add(formatAssetName(asset)); } metaFormData.append( "filename", deduplicateAssetName(sanitizeS3Key(fileName), existingNames) ); const authHeaders = new Headers(); if (authToken !== undefined) { authHeaders.set("x-auth-token", authToken); } const metaResponse = await fetch(restAssetsPath(), { method: "POST", body: metaFormData, headers: authHeaders, }); const metaData: { name: string } | { errors: string } = await metaResponse.json(); if ("errors" in metaData) { throw Error(metaData.errors); } const body = fileOrUrl instanceof File ? fileOrUrl : JSON.stringify({ url: fileOrUrl.href }); const headers = new Headers(authHeaders); if (fileOrUrl instanceof URL) { headers.set("Content-Type", "application/json"); } let width = undefined; let height = undefined; if (mimeType.startsWith("video/") && fileOrUrl instanceof File) { const videoSize = await getVideoDimensions(fileOrUrl); width = videoSize.width; height = videoSize.height; } const uploadResponse = await fetch( restAssetsUploadPath({ name: metaData.name, width, height }), { method: "POST", body, headers, } ); const uploadData: AssetActionResponse = await uploadResponse.json(); if ("errors" in uploadData) { throw Error(uploadData.errors); } onCompleted(uploadData); } catch (error) { if (error instanceof Error) { onError(error.message); } } }; const handleAfterSubmit = ( assetId: string, data: AssetActionResponse, projectId: string ) => { warnOnce( data.uploadedAssets?.length !== 1, "Expected exactly 1 uploaded asset" ); const uploadedAsset = data.uploadedAssets?.[0]; if (uploadedAsset === undefined) { warnOnce(true, "An uploaded asset is undefined"); toast.error("Could not upload an asset"); safeDeleteAssets([assetId], projectId); return; } // update store with new asset and set current id safeSetAsset({ ...uploadedAsset, id: assetId }, projectId); }; const imageWidth = css({ maxWidth: "100%", }); const ToastImageInfo = ({ objectURL }: { objectURL: string }) => { return ( ); }; const processingQueue: [ filesData: UploadingFileData[], projectId: string, authToken: string | undefined, ][] = []; const processUpload = async ( filesData: UploadingFileData[], projectId: string, authToken: string | undefined ) => { processingQueue.push([filesData, projectId, authToken]); if (processingQueue.length > 1) { return; } while (processingQueue.length > 0) { const [filesData, projectId, authToken] = processingQueue.shift()!; const currentProjectId = $project.get()?.id; if (currentProjectId !== projectId) { toast.error("Project has been changed, files will not be uploaded"); // Can cause data corrupiton continue; } for (const fileData of filesData) { const assetId = fileData.assetId; if ($assets.get().has(assetId)) { toast.info("Asset already exists", { icon: , }); deleteUploadingFileData(assetId); continue; } await uploadAsset({ authToken, projectId, fileOrUrl: fileData.source === "file" ? fileData.file : new URL(fileData.url), assetType: fileData.type, onCompleted: (data) => { URL.revokeObjectURL(fileData.objectURL); deleteUploadingFileData(assetId); handleAfterSubmit(assetId, data, projectId); }, onError: (error) => { deleteUploadingFileData(assetId); toast.error(error, { icon: , }); safeDeleteAssets([assetId], projectId); }, }); } } }; export const uploadAssets = async ( type: AssetType, filesOrUrls: T[] ): Promise> => { const projectId = $project.get()?.id; const authToken = $authToken.get(); if (projectId === undefined) { return new Map(); } const filesData = await getFilesData(type, filesOrUrls); // Filter out duplicates inside filesData const uniqFilesDataMap = new Map( filesData.map((fileData) => [fileData.assetId, fileData]) ); // Filter out duplicates existing in assets or uploading files const existingIds = [ ...$assets.get().keys(), ...$uploadingFilesDataStore.get().map((fileData) => fileData.assetId), ]; for (const existingAssetId of existingIds) { if (uniqFilesDataMap.has(existingAssetId)) { const fileData = uniqFilesDataMap.get(existingAssetId)!; uniqFilesDataMap.delete(existingAssetId); toast.info("Asset already exists", { icon: , }); } } const uniqFilesData = [...uniqFilesDataMap.values()]; addUploadingFilesData(uniqFilesData); processUpload(uniqFilesData, projectId, authToken); const res = new Map(); for (let i = 0; i < filesData.length; ++i) { const fileOrUrl = filesOrUrls[i]; const fileData = filesData[i]; invariant( fileOrUrl instanceof URL || (fileOrUrl instanceof File && fileData.source === "file" && fileData.file === fileOrUrl) ); invariant( fileOrUrl instanceof File || (fileOrUrl instanceof URL && fileData.source === "url" && fileData.url === fileOrUrl.href) ); res.set(filesOrUrls[i], filesData[i].assetId); } return res; }; export const __testing__ = { deduplicateAssetName, }; ================================================ FILE: apps/builder/app/builder/shared/assets/use-assets.test.ts ================================================ import { describe, test, expect } from "vitest"; import type { AssetContainer, UploadedAssetContainer } from "./types"; import type { Asset } from "@webstudio-is/sdk"; import { __testing__ } from "./use-assets"; const { filterByType } = __testing__; describe("use-assets", () => { describe("filterByType", () => { const createImageAsset = (): Asset => ({ id: "image-id", type: "image", name: "image-name", format: "png", size: 1000, meta: { width: 100, height: 100 }, createdAt: "2024-01-01", projectId: "project-id", }); const createFontAsset = (): Asset => ({ id: "font-id", type: "font", name: "font-name", format: "woff2", size: 1000, meta: { family: "TestFont", style: "normal", weight: 400 }, createdAt: "2024-01-01", projectId: "project-id", }); const createFileAsset = (): Asset => ({ id: "file-id", type: "file", name: "file-name", format: "pdf", size: 1000, meta: {}, createdAt: "2024-01-01", projectId: "project-id", }); const createUploadedContainer = (asset: Asset): UploadedAssetContainer => ({ status: "uploaded", asset, }); test("returns all containers when type is undefined", () => { const containers: AssetContainer[] = [ createUploadedContainer(createImageAsset()), createUploadedContainer(createFontAsset()), createUploadedContainer(createFileAsset()), ]; const result = filterByType(containers, undefined); expect(result).toEqual(containers); expect(result).toHaveLength(3); }); test("filters containers by image type", () => { const imageContainer = createUploadedContainer(createImageAsset()); const fontContainer = createUploadedContainer(createFontAsset()); const fileContainer = createUploadedContainer(createFileAsset()); const containers: AssetContainer[] = [ imageContainer, fontContainer, fileContainer, ]; const result = filterByType(containers, "image"); expect(result).toEqual([imageContainer]); expect(result).toHaveLength(1); }); test("filters containers by font type", () => { const imageContainer = createUploadedContainer(createImageAsset()); const fontContainer = createUploadedContainer(createFontAsset()); const containers: AssetContainer[] = [imageContainer, fontContainer]; const result = filterByType(containers, "font"); expect(result).toEqual([fontContainer]); expect(result).toHaveLength(1); }); test("returns empty array when no containers match type", () => { const imageContainer = createUploadedContainer(createImageAsset()); const containers: AssetContainer[] = [imageContainer]; const result = filterByType(containers, "font"); expect(result).toEqual([]); expect(result).toHaveLength(0); }); test("returns empty array when given empty array", () => { const result = filterByType([], "image"); expect(result).toEqual([]); expect(result).toHaveLength(0); }); test("filters multiple containers of same type", () => { const imageContainer1 = createUploadedContainer(createImageAsset()); const imageAsset2: Asset = { ...createImageAsset(), id: "image-id-2", }; const imageContainer2 = createUploadedContainer(imageAsset2); const fontContainer = createUploadedContainer(createFontAsset()); const containers: AssetContainer[] = [ imageContainer1, fontContainer, imageContainer2, ]; const result = filterByType(containers, "image"); expect(result).toHaveLength(2); expect(result).toEqual([imageContainer1, imageContainer2]); }); }); }); ================================================ FILE: apps/builder/app/builder/shared/assets/use-assets.tsx ================================================ import { useMemo } from "react"; import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import type { Asset } from "@webstudio-is/sdk"; import { $assets, $uploadingFilesDataStore } from "~/shared/nano-states"; import type { AssetContainer, UploadedAssetContainer, UploadingAssetContainer, } from "./types"; import { uploadingFileDataToAsset } from "./asset-utils"; const $assetContainers = computed( [$assets, $uploadingFilesDataStore], (assets, uploadingFilesData) => { const uploadingContainers: UploadingAssetContainer[] = []; for (const uploadingFile of uploadingFilesData) { uploadingContainers.push({ status: "uploading", objectURL: uploadingFile.objectURL, asset: uploadingFileDataToAsset(uploadingFile), }); } const uploadedContainers: UploadedAssetContainer[] = []; for (const asset of assets.values()) { uploadedContainers.push({ status: "uploaded", asset, }); } // sort newest uploaded assets first uploadedContainers.sort( (leftContainer, rightContainer) => new Date(rightContainer.asset.createdAt).getTime() - new Date(leftContainer.asset.createdAt).getTime() ); // put uploading assets first return [...uploadingContainers, ...uploadedContainers]; } ); const filterByType = ( assetContainers: AssetContainer[], type: Asset["type"] | undefined ) => { if (type === undefined) { return assetContainers; } return assetContainers.filter((assetContainer) => { return assetContainer.asset.type === type; }); }; export const useAssets = (type?: Asset["type"]) => { const assetContainers = useStore($assetContainers); const assetsByType = useMemo(() => { return filterByType(assetContainers, type); }, [assetContainers, type]); return { /** * Already loaded assets or assets that are being uploaded */ assetContainers: assetsByType, }; }; export const __testing__ = { filterByType, }; ================================================ FILE: apps/builder/app/builder/shared/binding-popover.test.ts ================================================ import { expect, test } from "vitest"; import { encodeDataSourceVariable } from "@webstudio-is/sdk"; import { evaluateExpressionWithinScope } from "./binding-popover"; test("evaluateExpressionWithinScope works", () => { const variableName = "jsonVariable"; const encVariableName = encodeDataSourceVariable(variableName); const variableValue = 1; expect( evaluateExpressionWithinScope(`${encVariableName} + ${encVariableName}`, { [encVariableName]: variableValue, }) ).toEqual(2); }); ================================================ FILE: apps/builder/app/builder/shared/binding-popover.tsx ================================================ import { type ButtonHTMLAttributes, forwardRef, useMemo, useRef, useState, type ReactNode, } from "react"; import { useStore } from "@nanostores/react"; import { DotIcon, InfoCircleIcon, PlusIcon, ResetIcon, TrashIcon, } from "@webstudio-is/icons"; import { Box, Button, CssValueListArrowFocus, CssValueListItem, DialogTitleActions, DialogClose, DialogTitle, Flex, FloatingPanel, Label, ScrollArea, SmallIconButton, Text, Tooltip, theme, } from "@webstudio-is/design-system"; import { decodeDataSourceVariable, getExpressionIdentifiers, lintExpression, } from "@webstudio-is/sdk"; import { $dataSourceVariables, $isDesignMode } from "~/shared/nano-states"; import { computeExpression, encodeDataVariableName, } from "~/shared/data-variables"; import { ExpressionEditor, formatValuePreview, type EditorApi, } from "./expression-editor"; /** * Check if a value is a primitive that can be safely stringified. * Allows: string, number, boolean, null, undefined * Rejects: object, array, function, symbol */ export const isPrimitiveValue = (value: unknown): boolean => { if (value === null || value === undefined) { return true; } const type = typeof value; return type === "string" || type === "number" || type === "boolean"; }; /** * Generate a validation error message for non-primitive values. * @param label - The control label (e.g., "Title", "URL") * @returns Error message or undefined if value is valid */ export const validatePrimitiveValue = ( value: unknown, label: string ): string | undefined => { if (!isPrimitiveValue(value)) { return `${label} expects a primitive value (string, number, boolean, null, or undefined), not an object, array, or function`; } }; export const evaluateExpressionWithinScope = ( expression: string, scope: Record ) => { const variables = new Map(); for (const [name, value] of Object.entries(scope)) { const decodedName = decodeDataSourceVariable(name); if (decodedName) { variables.set(decodedName, value); } } return computeExpression(expression, variables); }; const BindingPanel = ({ scope, aliases, valueError, value, onChange, onSave, }: { scope: Record; aliases: Map; valueError?: string; value: string; onChange: () => void; onSave: (value: string, invalid: boolean) => void; }) => { const editorApiRef = useRef(undefined); const [expression, setExpression] = useState(value); const usedIdentifiers = useMemo( () => getExpressionIdentifiers(value), [value] ); const [errorsCount, setErrorsCount] = useState(0); const [touched, setTouched] = useState(false); const scopeEntries = Object.entries(scope); const validate = (expression: string) => { const diagnostics = lintExpression({ expression, availableVariables: new Set(aliases.keys()), }); // prevent saving expression only with syntax error const errors = diagnostics.filter((item) => item.severity === "error"); setErrorsCount(errors.length); }; const updateExpression = (newExpression: string) => { setExpression(newExpression); onChange(); validate(newExpression); }; return ( Variables {scopeEntries.length === 0 && ( No variables available )} {scopeEntries.map(([identifier, value], index) => { const name = aliases.get(identifier); const label = value === undefined ? name : `${name}: ${formatValuePreview(value)}`; return ( {label}} // mark all variables used in expression as selected active={usedIdentifiers.has(identifier)} // convert variable to expression onClick={() => { if (name) { const nameIdentifier = encodeDataVariableName(name); editorApiRef.current?.replaceSelection(nameIdentifier); } }} // expression editor blur is fired after pointer down even // preventing it allows to not trigger validation // and flickering error tooltip onPointerDown={(event) => { event.preventDefault(); }} /> ); })} Expression editor Use JavaScript syntax to access variables along with comparison and arithmetic operators.
Use the dot notation to access nested object values: Variable.nested.value } >
0) || valueError !== undefined ? "error" : undefined } autoFocus={true} value={expression} onChange={(value) => { updateExpression(value); setTouched(false); }} onChangeComplete={() => { onSave(expression, errorsCount > 0); setTouched(true); }} />
); }; const bindingOpacityProperty = "--ws-binding-opacity"; export const BindingControl = ({ children }: { children: ReactNode }) => { return ( {children} ); }; export type BindingVariant = "default" | "bound" | "overwritten"; const BindingButton = forwardRef< HTMLButtonElement, ButtonHTMLAttributes & { variant: BindingVariant; error?: string; value: string; } >(({ variant, error, value, ...props }, ref) => { const expanded = props["aria-expanded"]; const overwrittenMessage = variant === "overwritten" ? ( Bound variable is overwritten with temporary value ) : undefined; const tooltipContent = error ?? overwrittenMessage; return ( // prevent giving content to tooltip when popover is open // to avoid button remounting and popover flickering // when switch between valid and error value } /> ); }); BindingButton.displayName = "BindingButton"; export const BindingPopover = ({ scope, aliases, variant, validate, value, onChange, onRemove, }: { scope: Record; aliases: Map; variant: BindingVariant; validate?: (value: unknown) => undefined | string; value: string; onChange: (newValue: string) => void; onRemove: (evaluatedValue: unknown) => void; }) => { const [isOpen, onOpenChange] = useState(false); const hasUnsavedChange = useRef(false); const preventedClosing = useRef(false); const isDesignMode = useStore($isDesignMode); if (!isDesignMode) { return; } const valueError = validate?.(evaluateExpressionWithinScope(value, scope)); return ( { // handle special case for popover close if (newOpen === false) { // prevent saving when changes are not saved or validated if (hasUnsavedChange.current) { // schedule closing after saving preventedClosing.current = true; return; } preventedClosing.current = false; } onOpenChange(newOpen); }} title={ {/* automatically close popover when remove expression */} ); }; type RenameCssVariableDialogProps = { cssVariable?: { property: string }; onClose: () => void; onConfirm: (oldProperty: string, newProperty: string) => void; }; export const RenameCssVariableDialog = ({ cssVariable, onClose, onConfirm, }: RenameCssVariableDialogProps) => { const [name, setName] = useState(""); const [error, setError] = useState(); // Reset name and clear error when cssVariable changes useEffect(() => { if (cssVariable?.property !== undefined) { setName(cssVariable.property); setError(undefined); } }, [cssVariable?.property]); const handleConfirm = () => { const renameError = renameCssVariable(cssVariable!.property, name); if (renameError) { setError(renameError.message); return; } onConfirm(cssVariable!.property, name); onClose(); }; return ( { if (isOpen === false) { onClose(); } }} > { // Prevent command panel from handling keyboard events event.stopPropagation(); if (event.key === "Enter" && !error) { handleConfirm(); } }} > Rename CSS Variable { setName(event.target.value); setError(undefined); }} color={error ? "error" : undefined} /> {error && ( {error} )} ); }; const DeleteUnusedCssVariablesDialogContent = ({ onClose, }: { onClose: () => void; }) => { const unusedVariables = useStore($unusedCssVariables); // Convert Set to Array for display const unusedVariablesArray = Array.from(unusedVariables); return ( {unusedVariablesArray.length === 0 ? ( There are no unused CSS variables to delete. ) : ( <> Delete {unusedVariablesArray.length} unused CSS{" "} {unusedVariablesArray.length === 1 ? "variable" : "variables"} from the project? {unusedVariablesArray.join(", ")} )} {unusedVariablesArray.length > 0 && ( )} ); }; export const DeleteUnusedCssVariablesDialog = () => { const open = useStore($isDeleteUnusedCssVariablesDialogOpen); const handleClose = () => { $isDeleteUnusedCssVariablesDialogOpen.set(false); }; return ( { if (isOpen === false) { handleClose(); } }} > { event.stopPropagation(); }} > Delete unused CSS variables ); }; ================================================ FILE: apps/builder/app/builder/shared/data-variable-utils.test.tsx ================================================ import { expect, test } from "vitest"; import { atom } from "nanostores"; import { $dataSources } from "~/shared/sync/data-stores"; import { validateDataVariableName } from "./data-variable-utils"; import type { DataSources, DataSource } from "@webstudio-is/sdk"; // Mock the nano-states module const mockDataSources = atom(new Map()); // Replace the actual store with our mock Object.defineProperty($dataSources, "get", { value: () => mockDataSources.get(), }); // Helper to create a minimal variable data source for testing const createVariable = ( id: string, name: string, scopeInstanceId?: string ): DataSource => ({ id, scopeInstanceId, name, type: "variable", value: { type: "string", value: "" }, }); test("validateDataVariableName returns required error for empty name", () => { mockDataSources.set(new Map()); const error = validateDataVariableName(""); expect(error?.type).toBe("required"); expect(error?.message).toBe("Variable name is required"); }); test("validateDataVariableName returns required error for whitespace-only name", () => { mockDataSources.set(new Map()); const error = validateDataVariableName(" "); expect(error?.type).toBe("required"); }); test("validateDataVariableName returns undefined for valid unique name", () => { mockDataSources.set( new Map([["var1", createVariable("var1", "existingVariable", "instance1")]]) ); const error = validateDataVariableName("newVariable"); expect(error).toBeUndefined(); }); test("validateDataVariableName returns duplicate error when name exists on same instance", () => { mockDataSources.set( new Map([ ["var1", createVariable("var1", "myVariable", "instance1")], ["var2", createVariable("var2", "otherVariable", "instance1")], ]) ); // Creating a new variable with existing name on same instance const error = validateDataVariableName("myVariable", "var2"); expect(error?.type).toBe("duplicate"); }); test("validateDataVariableName allows same name on different instances", () => { mockDataSources.set( new Map([ ["var1", createVariable("var1", "myVariable", "instance1")], ["var2", createVariable("var2", "otherVariable", "instance2")], ]) ); // Creating a variable on instance2 with name that exists on instance1 const error = validateDataVariableName("myVariable", "var2"); expect(error).toBeUndefined(); }); test("validateDataVariableName allows renaming variable to same name", () => { mockDataSources.set( new Map([["var1", createVariable("var1", "myVariable", "instance1")]]) ); // Renaming var1 to its current name const error = validateDataVariableName("myVariable", "var1"); expect(error).toBeUndefined(); }); test("validateDataVariableName returns duplicate error when renaming to existing name on same instance", () => { mockDataSources.set( new Map([ ["var1", createVariable("var1", "firstVariable", "instance1")], ["var2", createVariable("var2", "secondVariable", "instance1")], ]) ); // Renaming var2 to var1's name on same instance const error = validateDataVariableName("firstVariable", "var2"); expect(error?.type).toBe("duplicate"); }); test("validateDataVariableName ignores non-variable data sources", () => { mockDataSources.set( new Map([ ["var1", createVariable("var1", "myVariable", "instance1")], [ "resource1", { id: "resource1", scopeInstanceId: "instance1", name: "myResource", type: "resource", resourceId: "res1", }, ], ]) ); // Creating a variable with same name as resource should be allowed const error = validateDataVariableName("myResource"); expect(error).toBeUndefined(); }); test("validateDataVariableName validates for new variables without variableId", () => { mockDataSources.set( new Map([["var1", createVariable("var1", "existingVariable", "instance1")]]) ); // Creating a new variable (no variableId) with existing name on same instance const error = validateDataVariableName( "existingVariable", undefined, "instance1" ); expect(error?.type).toBe("duplicate"); }); test("validateDataVariableName handles undefined scopeInstanceId correctly", () => { mockDataSources.set( new Map([["var1", createVariable("var1", "globalVariable", undefined)]]) ); // Creating a variable with same name but undefined scope const error = validateDataVariableName("globalVariable"); expect(error?.type).toBe("duplicate"); }); ================================================ FILE: apps/builder/app/builder/shared/data-variable-utils.tsx ================================================ import { useState, useEffect } from "react"; import { atom, computed } from "nanostores"; import { useStore } from "@nanostores/react"; import { toast } from "@webstudio-is/design-system"; import { Dialog, DialogContent, DialogTitle, DialogClose, Flex, Text, Button, theme, InputField, } from "@webstudio-is/design-system"; import type { DataSource, Instance } from "@webstudio-is/sdk"; import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk"; import { $pages, $instances, $props, $dataSources, $resources, } from "~/shared/nano-states"; import { serverSyncStore } from "~/shared/sync/sync-stores"; import { findVariableUsagesByInstance } from "~/shared/data-variables"; const $isDeleteUnusedDataVariablesDialogOpen = atom(false); export const openDeleteUnusedDataVariablesDialog = () => { $isDeleteUnusedDataVariablesDialogOpen.set(true); }; export type DataVariableError = { type: "required" | "duplicate"; message: string; }; /** * Computed store that tracks which instances use each variable * Returns a Map of variable ID to Set of instance IDs */ export const $usedVariablesInInstances = computed( [$pages, $instances, $props, $dataSources, $resources], (pages, instances, props, dataSources, resources) => { return findVariableUsagesByInstance({ startingInstanceId: ROOT_INSTANCE_ID, pages, instances, props, dataSources, resources, }); } ); type DeleteDataVariableDialogProps = { variable?: { id: DataSource["id"]; name: string; usages: number }; onClose: () => void; onConfirm: (variableId: DataSource["id"]) => void; }; export const DeleteDataVariableDialog = ({ variable, onClose, onConfirm, }: DeleteDataVariableDialogProps) => { return ( { if (isOpen === false) { onClose(); } }} > { // Prevent command panel from handling keyboard events event.stopPropagation(); }} > Delete confirmation {variable && (variable.usages > 0 ? `Delete "${variable.name}" variable from the project? It is used in ${variable.usages} ${variable.usages === 1 ? "expression" : "expressions"}.` : `Delete "${variable.name}" variable from the project?`)} ); }; export const deleteUnusedDataVariables = () => { const dataSources = $dataSources.get(); const usedVariablesInInstances = $usedVariablesInInstances.get(); const unusedVariableIds: DataSource["id"][] = []; for (const dataSource of dataSources.values()) { if (dataSource.type === "variable") { const usages = usedVariablesInInstances.get(dataSource.id); if (usages === undefined || usages.size === 0) { unusedVariableIds.push(dataSource.id); } } } if (unusedVariableIds.length === 0) { return 0; } serverSyncStore.createTransaction( [$dataSources, $resources], (dataSources, resources) => { for (const variableId of unusedVariableIds) { const dataSource = dataSources.get(variableId); // Cleanup resource when variable is deleted if (dataSource?.type === "resource") { resources.delete(dataSource.resourceId); } dataSources.delete(variableId); } } ); return unusedVariableIds.length; }; export const validateDataVariableName = ( name: string, variableId?: DataSource["id"], scopeInstanceId?: Instance["id"] ): DataVariableError | undefined => { if (name.trim().length === 0) { return { type: "required", message: "Variable name is required", }; } const dataSources = $dataSources.get(); const currentVariable = variableId ? dataSources.get(variableId) : undefined; const actualScopeInstanceId = scopeInstanceId ?? (currentVariable?.type === "variable" ? currentVariable.scopeInstanceId : undefined); for (const dataSource of dataSources.values()) { if ( dataSource.type === "variable" && dataSource.scopeInstanceId === actualScopeInstanceId && dataSource.name === name && dataSource.id !== variableId ) { return { type: "duplicate", message: "Name is already used by another variable on this instance", }; } } }; export const renameDataVariable = ( id: DataSource["id"], name: string ): DataVariableError | undefined => { const validationError = validateDataVariableName(name, id); if (validationError) { return validationError; } serverSyncStore.createTransaction([$dataSources], (dataSources) => { const dataSource = dataSources.get(id); if (dataSource?.type === "variable") { dataSource.name = name; } }); }; type RenameDataVariableDialogProps = { variable?: { id: DataSource["id"]; name: string }; onClose: () => void; onConfirm: (variableId: DataSource["id"], newName: string) => void; }; export const RenameDataVariableDialog = ({ variable, onClose, onConfirm, }: RenameDataVariableDialogProps) => { const [name, setName] = useState(""); const [error, setError] = useState(); // Reset name and clear error when variable changes useEffect(() => { if (variable?.name !== undefined) { setName(variable.name); setError(undefined); } }, [variable?.id, variable?.name]); const handleConfirm = () => { const renameError = renameDataVariable(variable!.id, name); if (renameError) { setError(renameError.message); return; } onConfirm(variable!.id, name); onClose(); }; return ( { if (isOpen === false) { onClose(); } }} > { // Prevent command panel from handling keyboard events event.stopPropagation(); if (event.key === "Enter" && !error) { handleConfirm(); } }} > Rename variable { setName(event.target.value); setError(undefined); }} color={error ? "error" : undefined} /> {error && ( {error} )} ); }; export const DeleteUnusedDataVariablesDialog = () => { const open = useStore($isDeleteUnusedDataVariablesDialogOpen); const usedVariablesInInstances = useStore($usedVariablesInInstances); const dataSources = useStore($dataSources); const handleClose = () => { $isDeleteUnusedDataVariablesDialogOpen.set(false); }; const unusedVariables: Array<{ id: string; name: string }> = []; for (const dataSource of dataSources.values()) { if (dataSource.type === "variable") { const usages = usedVariablesInInstances.get(dataSource.id); if (usages === undefined || usages.size === 0) { unusedVariables.push({ id: dataSource.id, name: dataSource.name }); } } } return ( { if (isOpen === false) { handleClose(); } }} > { event.stopPropagation(); }} > Delete unused data variables {unusedVariables.length === 0 ? ( There are no unused data variables to delete. ) : ( <> Delete {unusedVariables.length} unused data{" "} {unusedVariables.length === 1 ? "variable" : "variables"} from the project? {unusedVariables.map((variable) => variable.name).join(", ")} )} {unusedVariables.length > 0 && ( )} ); }; ================================================ FILE: apps/builder/app/builder/shared/expression-editor.stories.tsx ================================================ import type { Meta, StoryObj } from "@storybook/react"; import { StorySection } from "@webstudio-is/design-system"; import { ExpressionEditor as ExpressionEditorComponent } from "./expression-editor"; import { useState } from "react"; export default { title: "Expression Editor", component: ExpressionEditorComponent, } satisfies Meta; const scope = { $ws$dataSource$123: { long: "!s fb skffsjdfksjdlkjslkkjlkj sjf lsdjsskljl kjsf", array: [], number: 0, boolean: true, object: { param: "value" }, }, $ws$dataSource$321: { my: "god" }, $ws$dataSource$computed: [ // 0-11 keys { "with space": 0 }, { "0_numeric": 0 }, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ], }; const aliases = new Map([ ["$ws$dataSource$123", "Hello world"], ["$ws$dataSource$321", "oh"], ["$ws$dataSource$computed", "computed"], ]); const ExpressionStory = () => { const [value, setValue] = useState("$ws$dataSource$123.world"); return ( ); }; export const ExpressionEditor: StoryObj = { render: () => (

{`Start typing "h" or "o" or press "Tab" to start variables completion`}

), }; ================================================ FILE: apps/builder/app/builder/shared/expression-editor.test.ts ================================================ import { describe, test, expect } from "vitest"; import { generateCompletionOptions } from "./expression-editor"; describe("generateCompletionOptions", () => { describe("object properties", () => { test("returns object properties with preview values", () => { const target = { name: "John", age: 30, email: "john@example.com" }; const options = generateCompletionOptions({ target, pathName: "", pathLength: 1, }); expect(options).toEqual([ { label: "name", detail: `"John"` }, { label: "age", detail: "30" }, { label: "email", detail: `"john@example.com"` }, ]); }); test("returns all properties regardless of pathName (filtering happens in CodeMirror layer)", () => { const target = { name: "John", age: 30, email: "john@example.com" }; const options = generateCompletionOptions({ target, pathName: "na", pathLength: 1, }); // generateCompletionOptions doesn't filter - that's done by matchSorter in scopeCompletionSource expect(options).toEqual([ { label: "name", detail: `"John"` }, { label: "age", detail: "30" }, { label: "email", detail: `"john@example.com"` }, ]); }); test("handles properties with special characters", () => { const target = { "prop with spaces": "value", "123": "numeric key" }; const options = generateCompletionOptions({ target, pathName: "", pathLength: 1, }); // Object.entries order for number-like keys may vary const labels = options.map((o) => o.label); expect(labels).toContain("prop with spaces"); expect(labels).toContain("123"); }); }); describe("array properties and methods", () => { test("returns array indices, length property, and methods", () => { const target = ["apple", "banana", "cherry"]; const options = generateCompletionOptions({ target, pathName: "", pathLength: 1, }); // Should include indices, length, and all array methods const labels = options.map((o) => o.label); expect(labels).toContain("0"); expect(labels).toContain("1"); expect(labels).toContain("2"); expect(labels).toContain("length"); expect(labels).toContain("slice()"); // Check specific values const lengthOption = options.find((o) => o.label === "length"); expect(lengthOption).toEqual({ label: "length", detail: "3", type: "property", }); }); test("filters length property by pathName", () => { const target = [1, 2, 3, 4, 5]; const options = generateCompletionOptions({ target, pathName: "len", pathLength: 1, }); expect(options).toContainEqual({ label: "length", detail: "5", type: "property", }); }); }); describe("string methods", () => { test("returns string methods when target is a string", () => { const target = "hello world"; const options = generateCompletionOptions({ target, pathName: "", pathLength: 1, }); const methodLabels = options.map((o) => o.label); expect(methodLabels).toContain("toUpperCase()"); expect(methodLabels).toContain("toLowerCase()"); expect(methodLabels).toContain("replace()"); }); test("filters string methods by pathName", () => { const target = "test string"; const options = generateCompletionOptions({ target, pathName: "upper", pathLength: 1, }); // Should include both toUpperCase and toLocaleUpperCase const labels = options.map((o) => o.label); expect(labels).toContain("toUpperCase()"); expect(labels).toContain("toLocaleUpperCase()"); }); test("does not return string methods for non-string targets", () => { const target = { name: "test" }; const options = generateCompletionOptions({ target, pathName: "upper", pathLength: 1, }); // Should only return object properties, no string methods const methodOptions = options.filter((o) => o.type === "method"); expect(methodOptions).toEqual([]); }); }); describe("pathLength === 0 (top-level variables)", () => { test("does not include methods for top-level variables", () => { const target = "hello"; const options = generateCompletionOptions({ target, pathName: "", pathLength: 0, }); expect(options).toEqual([]); }); test("returns array indices for top-level arrays (but no methods)", () => { const target = [1, 2, 3]; const options = generateCompletionOptions({ target, pathName: "", pathLength: 0, }); // At pathLength === 0, we get array indices but no length/methods expect(options).toEqual([ { label: "0", detail: "1" }, { label: "1", detail: "2" }, { label: "2", detail: "3" }, ]); }); }); describe("non-object targets", () => { test("returns empty array for null", () => { const options = generateCompletionOptions({ target: null, pathName: "", pathLength: 1, }); expect(options).toEqual([]); }); test("returns empty array for undefined", () => { const options = generateCompletionOptions({ target: undefined, pathName: "", pathLength: 1, }); expect(options).toEqual([]); }); test("returns empty array for primitive numbers", () => { const options = generateCompletionOptions({ target: 42, pathName: "", pathLength: 1, }); expect(options).toEqual([]); }); }); describe("edge cases", () => { test("handles empty object", () => { const options = generateCompletionOptions({ target: {}, pathName: "", pathLength: 1, }); expect(options).toEqual([]); }); test("handles empty array", () => { const options = generateCompletionOptions({ target: [], pathName: "", pathLength: 1, }); // Empty array has length and all array methods const labels = options.map((o) => o.label); expect(labels).toContain("length"); expect(labels).toContain("slice()"); const lengthOption = options.find((o) => o.label === "length"); expect(lengthOption).toEqual({ label: "length", detail: "0", type: "property", }); }); test("handles nested values in preview", () => { const target = { nested: { inner: "value" }, arr: [1, 2, 3], }; const options = generateCompletionOptions({ target, pathName: "", pathLength: 1, }); // formatValuePreview returns "JSON" for complex objects expect(options).toEqual([ { label: "nested", detail: "JSON" }, { label: "arr", detail: "JSON" }, ]); }); }); }); ================================================ FILE: apps/builder/app/builder/shared/expression-editor.tsx ================================================ import { useEffect, useMemo, type RefObject } from "react"; import { matchSorter } from "match-sorter"; import type { SyntaxNode } from "@lezer/common"; import { Facet, RangeSetBuilder } from "@codemirror/state"; import { type DecorationSet, type ViewUpdate, Decoration, WidgetType, ViewPlugin, keymap, EditorView, tooltips, } from "@codemirror/view"; import { bracketMatching, syntaxTree } from "@codemirror/language"; import { linter } from "@codemirror/lint"; import { type Completion, type CompletionSource, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, CompletionContext, insertCompletionText, pickedCompletion, } from "@codemirror/autocomplete"; import { javascript } from "@codemirror/lang-javascript"; import { textVariants, css, rawTheme } from "@webstudio-is/design-system"; import { decodeDataVariableId, lintExpression, allowedStringMethods, allowedArrayMethods, } from "@webstudio-is/sdk"; import { EditorContent, EditorDialog, EditorDialogButton, EditorDialogControl, type EditorApi, } from "~/shared/code-editor-base"; import { decodeDataVariableName, encodeDataVariableName, restoreExpressionVariables, unsetExpressionVariables, } from "~/shared/data-variables"; export type { EditorApi }; export const formatValue = (value: unknown) => { try { if (Array.isArray(value)) { // format arrays as multiline return JSON.stringify(value, null, 2); } if (typeof value === "object" && value !== null) { // format objects with parentheses to enforce correct // syntax highlighting as expression instead of block return `(${JSON.stringify(value, null, 2)})`; } return JSON.stringify(value); } catch { // show nothing when value is invalid return ""; } }; export const formatValuePreview = (value: unknown) => { if (typeof value === "string") { return JSON.stringify(value); } if (Number.isNaN(value)) { return "nan"; } if (typeof value === "number") { return value.toString(); } if (typeof value === "boolean") { return value.toString(); } if (value === undefined) { return ""; } if (value === null) { return "null"; } return "JSON"; }; type Scope = Record; type Aliases = Map; const VariablesData = Facet.define<{ scope: Scope; aliases: Aliases; }>(); // completion based on // https://github.com/codemirror/lang-javascript/blob/4dcee95aee9386fd2c8ad55f93e587b39d968489/src/complete.ts const Identifier = /^[\p{L}$][\p{L}\p{N}$]*$/u; const pathFor = ( read: (node: SyntaxNode) => string, member: SyntaxNode, name: string ) => { const path: string[] = []; // traverse from current node to the root variable for (;;) { const object = member.firstChild; if (object?.name === "VariableName") { path.push(read(object)); return { path: path.reverse(), name }; } if (object?.name === "MemberExpression") { // MemberExpression(SyntaxNode PropertyName) if (object.lastChild?.name === "PropertyName") { path.push(read(object.lastChild!)); member = object; continue; } // MemberExpression(SyntaxNode [ SyntaxNode ]) if (object.lastChild?.name === "]") { const computed = object.lastChild.prevSibling; if (computed?.name === "Number") { path.push(read(computed)); member = object; continue; } if (computed?.name === "String") { // trim quotes from string literal path.push(read(computed).slice(1, -1)); member = object; continue; } } } // unexpected case break; } }; /// Helper function for defining JavaScript completion sources. It /// returns the completable name and object path for a completion /// context, or undefined if no name/property completion should happen at /// that position. For example, when completing after `a.b.c` it will /// return `{path: ["a", "b"], name: "c"}`. When completing after `x` /// it will return `{path: [], name: "x"}`. When not in a property or /// name, it will return undefined if `context.explicit` is false, and /// `{path: [], name: ""}` otherwise. const completionPath = ( context: CompletionContext ): { path: string[]; name: string } | undefined => { const read = (node: SyntaxNode) => context.state.doc.sliceString(node.from, node.to); const inner = syntaxTree(context.state).resolveInner(context.pos, -1); // suggest global variable name when user start completion explicitly if (inner.name === "Script") { if (context.explicit) { return { path: [], name: "" }; } return; } // complete variable name when start entering if (inner.name === "VariableName") { return { path: [], name: read(inner) }; } // suggest property name when enter `object.` if (inner.name === "." && inner.parent?.name === "MemberExpression") { return pathFor(read, inner.parent, ""); } // complete property when enter "object.prope" if ( inner.name === "PropertyName" && inner.parent?.name === "MemberExpression" ) { return pathFor(read, inner.parent, read(inner)); } return; }; /** * Generate completion options for object properties and array/string methods. * Exported for testing. */ export const generateCompletionOptions = ({ target, pathName, pathLength, }: { target: unknown; pathName: string; pathLength: number; }): Array<{ label: string; detail: string; type?: string }> => { const options: Array<{ label: string; detail: string; type?: string }> = []; // Add object properties if (typeof target === "object" && target !== null) { for (const [name, value] of Object.entries(target)) { options.push({ label: name, detail: formatValuePreview(value), }); } } // Add string/array methods and properties for nested paths if (pathLength > 0) { const isString = typeof target === "string"; const isArray = Array.isArray(target); if (isString) { for (const method of allowedStringMethods) { if (method.toLowerCase().includes(pathName.toLowerCase())) { options.push({ label: `${method}()`, detail: "string method", type: "method", }); } } } if (isArray) { // Add length property if ("length".toLowerCase().includes(pathName.toLowerCase())) { options.push({ label: "length", detail: `${target.length}`, type: "property", }); } for (const method of allowedArrayMethods) { if (method.toLowerCase().includes(pathName.toLowerCase())) { options.push({ label: `${method}()`, detail: "array method", type: "method", }); } } } } return options; }; // Defines a completion source that completes from the given scope // object (for example `globalThis`). Will enter properties // of the object when completing properties on a directly-named path. const scopeCompletionSource: CompletionSource = (context) => { const [{ scope }] = context.state.facet(VariablesData); const path = completionPath(context); if (path === undefined) { return null; } // eslint-disable-next-line @typescript-eslint/no-explicit-any let target: any = scope; for (const step of path.path) { target = target?.[step]; if (target == null) { return null; } } // Generate base completion options using exported function const baseOptions = generateCompletionOptions({ target, pathName: path.name, pathLength: path.path.length, }); // Convert to CodeMirror Completion format with apply functions let options: Completion[] = baseOptions.map((option) => { const name = option.label; return { label: name, displayLabel: decodeDataVariableName(name), detail: option.detail, type: option.type, apply: (view, completion, from, to) => { const textToInsert = name; // complete valid js identifier or top level variable without quotes if (Identifier.test(textToInsert) || path.path.length === 0) { // complete with dot view.dispatch({ ...insertCompletionText(view.state, textToInsert, from, to), annotations: pickedCompletion.of(completion), }); } else { // complete with computed member expression view.dispatch({ ...insertCompletionText( view.state, // `param with spaces` -> ["param with spaces"] // `0` -> [0] `[${/^\d+$/.test(textToInsert) ? textToInsert : JSON.stringify(textToInsert)}]`, // remove dot when autocomplete computed member expression // variable. // variable["name"] from - 1, to ), annotations: pickedCompletion.of(completion), }); } }, }; }); // Sort options if target is an object if (typeof target === "object" && target !== null) { options = matchSorter(options, path.name, { keys: [(option) => option.displayLabel ?? option.label], baseSort: (left, right) => { const leftName = left.item.label; const rightName = right.item.label; const leftIndex = Number(leftName); const rightIndex = Number(rightName); // sort string fields if (Number.isNaN(leftIndex) || Number.isNaN(rightIndex)) { return leftName.localeCompare(rightName); } // sort indexes if both numbers return leftIndex - rightIndex; }, }); } return { from: context.pos - path.name.length, filter: false, options, }; }; /** * Highlight variables and replace their $ws$dataSource$name like labels * with user names * * https://codemirror.net/examples/decoration/#atomic-ranges */ class VariableWidget extends WidgetType { text: string; constructor(text: string) { super(); this.text = text; } toDOM(): HTMLElement { const span = document.createElement("span"); span.style.backgroundColor = "rgba(24, 119, 232, 0.2)"; span.textContent = this.text; return span; } } const getVariableDecorations = (view: EditorView) => { const builder = new RangeSetBuilder(); syntaxTree(view.state).iterate({ from: 0, to: view.state.doc.length, enter: (node) => { if (node.name === "VariableName") { const [{ scope }] = view.state.facet(VariablesData); const identifier = view.state.doc.sliceString(node.from, node.to); const variableName = decodeDataVariableName(identifier); if (identifier in scope) { builder.add( node.from, node.to, Decoration.replace({ widget: new VariableWidget(variableName!), }) ); } } }, }); return builder.finish(); }; const variablesPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = getVariableDecorations(view); } update(update: ViewUpdate) { if (update.docChanged) { this.decorations = getVariableDecorations(update.view); } } }, { decorations: (instance) => instance.decorations, provide: (plugin) => EditorView.atomicRanges.of((view) => { return view.plugin(plugin)?.decorations || Decoration.none; }), } ); const emptyScope: Scope = {}; const emptyAliases: Aliases = new Map(); const wrapperStyle = css({ // 1 line is 16px // set and max 20 lines "--ws-code-editor-max-height": "320px", }); const linterTooltipTheme = EditorView.theme({ ".cm-tooltip:has(.cm-tooltip-lint)": { backgroundColor: "transparent", borderWidth: 0, paddingTop: rawTheme.spacing[5], paddingBottom: rawTheme.spacing[5], pointerEvents: "none", }, ".cm-tooltip-lint": { backgroundColor: rawTheme.colors.backgroundTooltipMain, color: rawTheme.colors.foregroundContrastMain, borderRadius: rawTheme.borderRadius[7], padding: rawTheme.spacing[5], }, ".cm-tooltip-lint .cm-diagnostic": { borderWidth: 0, padding: 0, margin: 0, ...textVariants.regular, }, }); const expressionLinter = linter((view) => { const [{ scope }] = view.state.facet(VariablesData); return lintExpression({ expression: view.state.doc.toString(), availableVariables: new Set(Object.keys(scope)), }); }); export const ExpressionEditor = ({ editorApiRef, scope = emptyScope, aliases = emptyAliases, color, autoFocus = false, readOnly = false, value, onChange, onChangeComplete, }: { editorApiRef?: RefObject; /** * object with variables and their data to autocomplete */ scope?: Scope; /** * variable aliases to show instead of $ws$dataSource$id */ aliases?: Aliases; color?: "error"; autoFocus?: boolean; readOnly?: boolean; value: string; onChange: (value: string) => void; onChangeComplete: (value: string) => void; }) => { const { nameById, idByName } = useMemo(() => { const nameById = new Map(); const idByName = new Map(); for (const [identifier, name] of aliases) { const id = decodeDataVariableId(identifier); if (id) { nameById.set(id, name); idByName.set(name, id); } } return { nameById, idByName }; }, [aliases]); const expressionWithUnsetVariables = useMemo(() => { return unsetExpressionVariables({ expression: value, unsetNameById: nameById, }); }, [value, nameById]); const scopeWithUnsetVariables = useMemo(() => { const newScope: typeof scope = {}; for (const [identifier, value] of Object.entries(scope)) { const name = aliases.get(identifier); if (name) { newScope[encodeDataVariableName(name)] = value; } } return newScope; }, [scope, aliases]); const aliasesWithUnsetVariables = useMemo(() => { const newAliases: typeof aliases = new Map(); for (const [_identifier, name] of aliases) { newAliases.set(encodeDataVariableName(name), name); } return newAliases; }, [aliases]); const extensions = useMemo( () => [ bracketMatching(), closeBrackets(), javascript({}), VariablesData.of({ scope: scopeWithUnsetVariables, aliases: aliasesWithUnsetVariables, }), // render autocomplete in body // to prevent popover scroll overflow tooltips({ parent: document.body }), autocompletion({ override: [scopeCompletionSource], icons: false, }), variablesPlugin, keymap.of([...closeBracketsKeymap, ...completionKeymap]), expressionLinter, linterTooltipTheme, ], [scopeWithUnsetVariables, aliasesWithUnsetVariables] ); // prevent clicking on autocomplete options propagating to body // and closing dialogs and popovers useEffect(() => { const handlePointerDown = (event: PointerEvent) => { if ( event.target instanceof HTMLElement && event.target.closest(".cm-tooltip-autocomplete") ) { event.stopPropagation(); } }; const options = { capture: true }; document.addEventListener("pointerdown", handlePointerDown, options); return () => { document.removeEventListener("pointerdown", handlePointerDown, options); }; }, []); const content = ( { const expressionWithRestoredVariables = restoreExpressionVariables({ expression: newValue, maskedIdByName: idByName, }); onChange(expressionWithRestoredVariables); }} onChangeComplete={(newValue: string) => { const expressionWithRestoredVariables = restoreExpressionVariables({ expression: newValue, maskedIdByName: idByName, }); onChangeComplete(expressionWithRestoredVariables); }} /> ); return (
{content}
); }; ================================================ FILE: apps/builder/app/builder/shared/fonts-manager/fonts-manager.tsx ================================================ import { DeprecatedList, DeprecatedListItem, useDeprecatedList, theme, useSearchFieldKeys, findNextListItemIndex, Tooltip, Text, rawTheme, Link, Flex, } from "@webstudio-is/design-system"; import { AssetsShell, deleteAssets, Separator, useAssets, } from "~/builder/shared/assets"; import { useMemo, useState } from "react"; import { useMenu } from "./item-menu"; import { CheckMarkIcon, InfoCircleIcon } from "@webstudio-is/icons"; import { type Item, filterIdsByFamily, filterItems, groupItemsByType, toItems, } from "./item-utils"; import type { FontFamilyValue } from "@webstudio-is/css-engine"; const useLogic = ({ onChange, value }: FontsManagerProps) => { const { assetContainers } = useAssets("font"); const [selectedIndex, setSelectedIndex] = useState(-1); const fontItems = useMemo(() => toItems(assetContainers), [assetContainers]); const searchProps = useSearchFieldKeys({ onMove({ direction }) { if (direction === "current") { handleChangeCurrent(selectedIndex); return; } const nextIndex = findNextListItemIndex( selectedIndex, groupedItems.length, direction ); setSelectedIndex(nextIndex); }, }); const filteredItems = useMemo( () => searchProps.value === "" ? fontItems : filterItems(searchProps.value, fontItems), [fontItems, searchProps.value] ); const { uploadedItems, systemItems, groupedItems } = useMemo( () => groupItemsByType(filteredItems), [filteredItems] ); const currentIndex = useMemo(() => { return groupedItems.findIndex((item) => item.label === value?.value[0]); }, [groupedItems, value]); const handleChangeCurrent = (nextCurrentIndex: number) => { const item = groupedItems[nextCurrentIndex]; if (item !== undefined) { onChange(item.label); } }; const { getItemProps, getListProps } = useDeprecatedList({ items: groupedItems, selectedIndex, currentIndex, onSelect: setSelectedIndex, onChangeCurrent: handleChangeCurrent, }); const handleDelete = (index: number) => { const family = groupedItems[index].label; const ids = filterIdsByFamily(family, assetContainers); deleteAssets(ids); if (index === currentIndex) { onChange(undefined); } }; return { groupedItems, uploadedItems, systemItems, selectedIndex, handleDelete, handleSelect: setSelectedIndex, getItemProps, getListProps, searchProps, }; }; type FontsManagerProps = { value?: FontFamilyValue; onChange: (value?: string) => void; }; export const FontsManager = ({ value, onChange }: FontsManagerProps) => { const { groupedItems, uploadedItems, systemItems, handleDelete, handleSelect, selectedIndex, getListProps, getItemProps, searchProps, } = useLogic({ onChange, value }); const listProps = getListProps(); const { render: renderMenu, isOpen: isMenuOpen } = useMenu({ selectedIndex, onSelect: handleSelect, onDelete: handleDelete, }); const renderItem = (item: Item, index: number) => { const { key, ...itemProps } = getItemProps({ index }); return ( : undefined} suffix={ item.type === "uploaded" ? ( renderMenu(index) ) : itemProps.state === "selected" && item.description ? ( {item.label} {`font-family: ${item.stack.join(", ")};`} {item.description} } > ) : undefined } > {item.label} ); }; return ( { if (isMenuOpen === false) { listProps.onBlur(event); } }} > {uploadedItems.length !== 0 && ( {"Uploaded"} )} {uploadedItems.map(renderItem)} {systemItems.length !== 0 && ( <> {uploadedItems.length !== 0 && } { "System font stack CSS organized by typeface classification for every modern OS. No downloading, no layout shifts, no flashes— just instant renders. Learn more about " } modern font stacks . } >
} > System )} {systemItems.map((item, index) => renderItem(item, index + uploadedItems.length) )} ); }; ================================================ FILE: apps/builder/app/builder/shared/fonts-manager/index.ts ================================================ export * from "./fonts-manager"; export { toItems } from "./item-utils"; ================================================ FILE: apps/builder/app/builder/shared/fonts-manager/item-menu.tsx ================================================ import { useStore } from "@nanostores/react"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, Text, styled, Tooltip, SmallIconButton, } from "@webstudio-is/design-system"; import { EllipsesIcon } from "@webstudio-is/icons"; import { type FocusEventHandler, useState, useRef, useEffect } from "react"; import { theme } from "@webstudio-is/design-system"; import { $authPermit } from "~/shared/nano-states"; const MenuButton = styled(SmallIconButton, { "&:hover, &:focus-visible": { color: theme.colors.foregroundMain, }, }); type ItemMenuProps = { onDelete: () => void; onOpenChange: (open: boolean) => void; onFocusTrigger?: FocusEventHandler; onBlurTrigger?: FocusEventHandler; }; const ItemMenu = ({ onDelete, onOpenChange, onFocusTrigger, onBlurTrigger, }: ItemMenuProps) => { const [isOpen, setIsOpen] = useState(false); const isMounted = useRef(false); useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; }; }, []); const authPermit = useStore($authPermit); const isDeleteDisabled = authPermit === "view"; const tooltipContent = isDeleteDisabled ? "View mode. You can't delete assets." : undefined; return ( { // Apparently onOpenChange can be called after component was unmounted and results in warning. // Use case: deleting list item removes the item itself while menu is open. if (isMounted.current) { setIsOpen(open); } onOpenChange(open); }} > { // Prevent setting the current font to the item. event.stopPropagation(); }} icon={} /> { // Prevent setting the current font to the item. event.stopPropagation(); }} onSelect={() => { onDelete(); }} > Delete font ); }; type UseMenu = { selectedIndex: number; onSelect: (index: number) => void; onDelete: (index: number) => void; }; export const useMenu = ({ selectedIndex, onSelect, onDelete }: UseMenu) => { const openMenu = useRef(-1); const focusedMenuTrigger = useRef(-1); const render = (index: number) => { const show = selectedIndex === index || openMenu.current === index || focusedMenuTrigger.current === index; if (show === false) { return; } return ( { openMenu.current = open === true ? index : -1; onSelect(index); }} onDelete={() => { onDelete(index); }} onFocusTrigger={() => { focusedMenuTrigger.current = index; onSelect(-1); }} onBlurTrigger={() => { focusedMenuTrigger.current = -1; }} /> ); }; return { render, isOpen: openMenu.current !== -1, }; }; ================================================ FILE: apps/builder/app/builder/shared/fonts-manager/item-utils.ts ================================================ import { SYSTEM_FONTS } from "@webstudio-is/fonts"; import { matchSorter } from "match-sorter"; import type { AssetContainer } from "../assets"; export type Item = { label: string; type: "uploaded" | "system"; description?: string; stack: Array; }; export const toItems = ( assetContainers: Array ): Array => { // We can have 2+ assets with the same family name, so we use a map to dedupe. const uploaded = new Map(); for (const assetContainer of assetContainers) { if (assetContainer.status !== "uploaded") { continue; } const { asset } = assetContainer; // @todo need to teach ts the right type from useAssets if ("meta" in asset && "family" in asset.meta) { uploaded.set(asset.meta.family, { label: asset.meta.family, type: "uploaded", }); } } const system = []; for (const [label, config] of SYSTEM_FONTS) { system.push({ label, type: "system", description: config.description, stack: config.stack, }); } return [...uploaded.values(), ...system]; }; export const filterIdsByFamily = ( family: string, assetContainers: Array ) => { // One family may have multiple assets for different formats, so we need to find them all. return assetContainers .filter((assetContainer) => { if (assetContainer.status !== "uploaded") { return false; } const { asset } = assetContainer; // @todo need to teach TS the right type from useAssets return ( "meta" in asset && "family" in asset.meta && asset.meta.family === family ); }) .map((assetContainer) => assetContainer.asset!.id); }; export const groupItemsByType = (items: Array) => { const uploadedItems = items.filter((item) => item.type === "uploaded"); const systemItems = items.filter((item) => item.type === "system"); const groupedItems = [...uploadedItems, ...systemItems]; return { uploadedItems, systemItems, groupedItems }; }; export const filterItems = (search: string, items: Array) => { return matchSorter(items, search, { keys: [(item) => item.label], }); }; ================================================ FILE: apps/builder/app/builder/shared/inert-handlers.ts ================================================ import { useCallback } from "react"; import { canvasApi } from "~/shared/canvas-api"; import { $textEditingInstanceSelector } from "~/shared/nano-states"; export const skipInertHandlersAttribute = "data-ws-skip-inert-handlers"; export const useInertHandlers = () => { /** * Prevents Lexical text editor from stealing focus during rendering. * Sets the inert attribute on the canvas body element and disables the text editor. * * This must be done synchronously to avoid the following issue: * * 1. Text editor is in edit state. * 2. User focuses on the builder (e.g., clicks any input). * 3. The text editor blur event triggers, causing a rerender on data change (data saved in onBlur). * 4. Text editor rerenders, stealing focus from the builder. * 5. Inert attribute is set asynchronously, but focus is already lost. * * Synchronous focusing and setInert prevent the text editor from focusing on render. * This cannot be handled inside the canvas because the text editor toolbar is in the builder and focus events in the canvas should be ignored. * * Use onPointerDown instead of onFocus because Radix focus lock triggers on text edit blur * before the focusin event when editing text inside a Radix dialog. */ const onPointerDown = useCallback((event: React.PointerEvent) => { if ( event.target instanceof Element && event.target.closest(`[${skipInertHandlersAttribute}]`) ) { return; } // Ignore toolbar focus events. See the onFocus handler in text-toolbar.tsx if (false === event.defaultPrevented) { canvasApi.setInert(); $textEditingInstanceSelector.set(undefined); } }, []); /** * Prevent Radix from stealing focus during editing in the style sources * For example, when the user select or create new style source item inside a dialog. */ const onKeyDown = useCallback((event: React.KeyboardEvent) => { if ( event.target instanceof Element && event.target.closest(`[${skipInertHandlersAttribute}]`) ) { return; } if (event.target instanceof HTMLInputElement) { canvasApi.setInert(); } }, []); /** * Prevent Radix from stealing focus during editing in the settings panel. * For example, when the user modifies the text content of an H1 element inside a dialog. */ const onInput = useCallback((event: React.FormEvent) => { if ( event.target instanceof Element && event.target.closest(`[${skipInertHandlersAttribute}]`) ) { return; } canvasApi.setInert(); }, []); return { onInput, onKeyDown, onPointerDown, }; }; ================================================ FILE: apps/builder/app/builder/shared/instance-context-menu.tsx ================================================ import type { ReactNode } from "react"; import { useStore } from "@nanostores/react"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuItemRightSlot, ContextMenuSeparator, ContextMenuTrigger, theme, Kbd, Box, } from "@webstudio-is/design-system"; import { showAttribute } from "@webstudio-is/react-sdk"; import { emitCommand } from "./commands"; import { $selectedInstancePath, $selectedPage, getInstanceKey, } from "~/shared/awareness"; import { canUnwrapInstance } from "~/shared/instance-utils"; import { $propValuesByInstanceSelector } from "~/shared/nano-states"; import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk"; import type { InstancePath } from "~/shared/awareness"; const getMenuPermissions = (instancePath: InstancePath | undefined) => { const instanceId = instancePath?.[0]?.instance.id; const rootInstanceId = $selectedPage.get()?.rootInstanceId; const isRoot = instanceId === ROOT_INSTANCE_ID; const isBody = instanceId === rootInstanceId; const isRootOrBody = isRoot || isBody; const canUnwrap = instancePath ? canUnwrapInstance(instancePath) : false; return { canCopy: !isRootOrBody, canPaste: !isRoot, canCut: !isRootOrBody, canDuplicate: !isRootOrBody, canHide: !isRoot, canRename: !isRoot, canWrap: !isRootOrBody, canUnwrap, canConvert: !isRootOrBody, canDelete: !isRootOrBody, }; }; export const MenuItems = () => { const instancePath = useStore($selectedInstancePath); const propValues = useStore($propValuesByInstanceSelector); const instanceSelector = instancePath?.[0]?.instanceSelector; const show = instanceSelector ? Boolean( propValues.get(getInstanceKey(instanceSelector))?.get(showAttribute) ?? true ) : true; const permissions = getMenuPermissions(instancePath); return ( { emitCommand("copy"); }} > Copy { emitCommand("paste"); }} > Paste { emitCommand("cut"); }} > Cut { emitCommand("duplicateInstance"); }} > Duplicate { emitCommand("toggleShow"); }} > {show ? "Hide" : "Show"} { emitCommand("editInstanceLabel"); }} > Rename { emitCommand("wrap"); }} > Wrap { emitCommand("unwrap"); }} > Unwrap { emitCommand("convert"); }} > Convert { emitCommand("focusStyleSources"); }} > Add token { emitCommand("openSettingsPanel"); }} > Open settings { emitCommand("deleteInstanceBuilder"); }} > Delete ); }; export const InstanceContextMenu = ({ children }: { children: ReactNode }) => { return ( { if (!(event.target instanceof HTMLElement)) { return; } event.target.closest("[data-tree-button]")?.click(); }} > {children} ); }; ================================================ FILE: apps/builder/app/builder/shared/instance-label.tsx ================================================ import type { HtmlTags } from "html-tags"; import { useStore } from "@nanostores/react"; import { BlockquoteIcon, BodyIcon, BoldIcon, BoxIcon, BracesIcon, ButtonElementIcon, CalendarIcon, FormIcon, FormTextAreaIcon, FormTextFieldIcon, HeadingIcon, ImageIcon, ItemIcon, LabelIcon, LinkIcon, ListIcon, ListItemIcon, MinusIcon, SelectIcon, SubscriptIcon, SuperscriptIcon, TextAlignLeftIcon, TextItalicIcon, } from "@webstudio-is/icons/svg"; import { elementComponent, parseComponentName, ROOT_INSTANCE_ID, type Instance, } from "@webstudio-is/sdk"; import { $instances } from "~/shared/sync/data-stores"; import { $registeredComponentMetas } from "~/shared/nano-states"; import { humanizeString } from "~/shared/string-utils"; const htmlIcons: Record = { // typography h1: HeadingIcon, h2: HeadingIcon, h3: HeadingIcon, h4: HeadingIcon, h5: HeadingIcon, h6: HeadingIcon, p: TextAlignLeftIcon, blockquote: BlockquoteIcon, code: BracesIcon, ul: ListIcon, ol: ListIcon, li: ListItemIcon, hr: MinusIcon, // rich text b: BoldIcon, strong: BoldIcon, i: TextItalicIcon, em: TextItalicIcon, sub: SubscriptIcon, sup: SuperscriptIcon, a: LinkIcon, // form form: FormIcon, textarea: FormTextAreaIcon, button: ButtonElementIcon, input: FormTextFieldIcon, label: LabelIcon, select: SelectIcon, option: ItemIcon, // misc body: BodyIcon, time: CalendarIcon, img: ImageIcon, } satisfies Partial>; type InstanceLike = { component: string; label?: string; tag?: string; }; type Props = { size?: number | string; instance: InstanceLike; icon?: string; }; export const InstanceIcon = ({ size = 16, instance, icon }: Props) => { const metas = useStore($registeredComponentMetas); const meta = metas.get(instance.component); // element component should be treated as div when no tag specified const elementTag = instance.component === elementComponent ? "div" : undefined; const tag = instance.tag ?? elementTag ?? Object.keys(meta?.presetStyle ?? {})[0]; const computedIcon = icon ?? meta?.icon ?? htmlIcons[tag] ?? BoxIcon; return (
); }; const getLabelFromComponentName = (component: Instance["component"]) => { const [_namespace, componentName] = parseComponentName(component); return humanizeString(componentName); }; export const getInstanceLabel = ( instanceOrInstanceId: InstanceLike | string ): string => { if (typeof instanceOrInstanceId === "string") { if (instanceOrInstanceId === ROOT_INSTANCE_ID) { return "Root"; } const instance = $instances.get().get(instanceOrInstanceId); if (instance) { return getInstanceLabel(instance); } return "Unknown"; } if (instanceOrInstanceId.label) { return instanceOrInstanceId.label; } if ( instanceOrInstanceId.component === elementComponent && instanceOrInstanceId.tag ) { return `<${instanceOrInstanceId.tag}>`; } const meta = $registeredComponentMetas .get() .get(instanceOrInstanceId.component); return ( meta?.label || getLabelFromComponentName(instanceOrInstanceId.component) ); }; ================================================ FILE: apps/builder/app/builder/shared/loading.stories.tsx ================================================ import { Flex, StorySection, Text, theme } from "@webstudio-is/design-system"; import { Loading as LoadingComponent, LoadingBackground } from "./loading"; export default { title: "Builder/Shared/Loading", component: LoadingComponent, }; export const Loading = () => ( Loading at 30% Loading at 80% Background overlay (visible) ); ================================================ FILE: apps/builder/app/builder/shared/loading.tsx ================================================ import { useState } from "react"; import { Box, Flex, Progress, theme } from "@webstudio-is/design-system"; import { WebstudioIcon } from "@webstudio-is/icons"; import { useInterval } from "~/shared/hook-utils/use-interval"; export const LoadingBackground = ({ show, onTransitionEnd, }: { show: boolean; onTransitionEnd?: () => void; }) => { const [transitionEnded, setTransitionEnded] = useState(false); if (transitionEnded) { return; } return ( { setTransitionEnded(true); onTransitionEnd?.(); }} /> ); }; type LoadingState = { state: "ready" | "loading"; progress: number; readyStates: Map< "dataLoadingState" | "selectedInstanceRenderState" | "canvasIframeState", boolean >; }; export const Loading = ({ state }: { state: LoadingState }) => { const [fakeProgress, setFakeProgress] = useState(state.progress); const [transitionEnded, setTransitionEnded] = useState(false); useInterval((intervalId) => { setFakeProgress((previousFakeValue) => { // Makeing sure fake value is not higher than real value to prevent jumping back let nextFakeValue = Math.max(previousFakeValue + 1, state.progress); // Make sure fake value is not lower than 10 to avoid showing empty progress. nextFakeValue = Math.max(nextFakeValue, 10); // Making sure fake value can't get bigger than 100, now that it reached 100 we can stop faking it. if (nextFakeValue >= 100) { clearInterval(intervalId); return previousFakeValue; } return nextFakeValue; }); }, 100); if (state.state === "ready" && transitionEnded) { return; } return ( { setTransitionEnded(true); }} /> {state.state !== "ready" && ( )} ); }; ================================================ FILE: apps/builder/app/builder/shared/nano-states.ts ================================================ import { atom, computed } from "nanostores"; import { $isPreviewMode, $selectedInstanceRenderState, } from "~/shared/nano-states/misc"; import { $canvasIframeState } from "~/shared/nano-states/canvas"; import type { SidebarPanelName } from "~/builder/sidebar-left/types"; import { $settings, getSetting } from "./client-settings"; export const $isShareDialogOpen = atom(false); export const $publishDialog = atom<"none" | "publish" | "export">("none"); export const $canvasWidth = atom(); export const $isCloneDialogOpen = atom(false); export const $canvasRect = atom(); export const $workspaceRect = atom(); export const $canvasScrollbarSize = atom< { width: number; height: number } | undefined >(); export const $scale = computed( [$canvasWidth, $workspaceRect], (canvasWidth, workspaceRect) => { if ( canvasWidth === undefined || workspaceRect === undefined || canvasWidth <= workspaceRect.width ) { return 100; } return Number.parseFloat( ((workspaceRect.width / canvasWidth) * 100).toFixed(2) ); } ); export const $clampingRect = computed( [$workspaceRect, $canvasRect, $canvasScrollbarSize, $scale], (workspaceRect, canvasRect, canvasScrollbarSize, scale) => { if ( workspaceRect === undefined || canvasRect === undefined || canvasScrollbarSize === undefined ) { return; } const scrollbarWidthScaled = Math.round( (canvasScrollbarSize.width * scale) / 100 ); const scrollbarHeightScaled = Math.round( (canvasScrollbarSize.height * scale) / 100 ); if (canvasRect.width >= workspaceRect.width) { return { left: 0, top: 0, width: workspaceRect.width - scrollbarWidthScaled, height: workspaceRect.height - scrollbarHeightScaled, }; } return { left: 0, top: 0, width: canvasRect.width - scrollbarWidthScaled, height: canvasRect.height - scrollbarHeightScaled, }; } ); export const $activeInspectorPanel = atom<"style" | "settings">("style"); export const $dataLoadingState = atom<"idle" | "loading" | "loaded">("idle"); export const $loadingState = computed( [$dataLoadingState, $selectedInstanceRenderState, $canvasIframeState], (dataLoadingState, selectedInstanceRenderState, canvasIframeState) => { const readyStates = new Map< "dataLoadingState" | "selectedInstanceRenderState" | "canvasIframeState", boolean >([ ["dataLoadingState", dataLoadingState === "loaded"], [ "selectedInstanceRenderState", selectedInstanceRenderState !== "pending", ], ["canvasIframeState", canvasIframeState === "ready"], ]); const readyCount = Array.from(readyStates.values()).filter(Boolean).length; const progress = Math.round((readyCount / readyStates.size) * 100); const state: "ready" | "loading" = readyCount === readyStates.size ? "ready" : "loading"; return { state, progress, readyStates }; } ); // Only used internally to avoid directly setting the value without using setActiveSidebarPanel. const $activeSidebarPanel_ = atom(); export const $activeSidebarPanel = computed( [$activeSidebarPanel_, $isPreviewMode, $loadingState, $settings], (currentPanel, isPreviewMode, loadingState, { navigatorLayout }) => { if (loadingState.state !== "ready") { return "none"; } if (isPreviewMode) { return currentPanel === "pages" ? "pages" : "none"; } if (currentPanel === undefined) { return navigatorLayout === "undocked" ? "navigator" : "none"; } return currentPanel; } ); /** * auto shows default panel when sidepanel is undocked and hides when docked */ export const setActiveSidebarPanel = (nextPanel: "auto" | SidebarPanelName) => { const currentPanel = $activeSidebarPanel.get(); // - When navigator is open, user is trying to close the navigator. // - Navigator is closed, user is trying to close some other panel, and if navigator is undocked, it needs to be opened. if (nextPanel === "none") { if (currentPanel === "navigator") { $activeSidebarPanel_.set("none"); return; } if (getSetting("navigatorLayout") === "undocked") { $activeSidebarPanel_.set("navigator"); return; } } $activeSidebarPanel_.set(nextPanel === "auto" ? undefined : nextPanel); }; export const toggleActiveSidebarPanel = (panel: SidebarPanelName) => { const currentPanel = $activeSidebarPanel.get(); setActiveSidebarPanel(panel === currentPanel ? "none" : panel); }; export const $remoteDialog = atom<{ title: string; url: string } | undefined>(); // Track which grid track (column/row) is being edited in the style panel export type GridEditingTrack = { type: "column" | "row"; index: number; }; export const $gridEditingTrack = atom(undefined); // Track which grid area is being edited in the style panel export type GridEditingArea = { columnStart: number; columnEnd: number; rowStart: number; rowEnd: number; }; export const $gridEditingArea = atom(undefined); // Whether a grid-related section is visible in the style panel. // Grid guides are shown on the canvas when true. export const $isStylePanelGridVisible = atom(false); ================================================ FILE: apps/builder/app/builder/shared/relative-time.stories.tsx ================================================ import { Flex, StorySection, Text } from "@webstudio-is/design-system"; import { RelativeTime as RelativeTimeComponent } from "./relative-time"; export default { title: "Builder/Shared/Relative Time", component: RelativeTimeComponent, }; export const RelativeTime = () => { const now = Date.now(); const times = [ { label: "Just now", date: new Date(now - 10_000) }, { label: "5 minutes ago", date: new Date(now - 5 * 60_000) }, { label: "1 hour ago", date: new Date(now - 60 * 60_000) }, { label: "Yesterday", date: new Date(now - 24 * 60 * 60_000) }, { label: "Last week", date: new Date(now - 7 * 24 * 60 * 60_000) }, ]; return ( {times.map(({ label, date }) => ( {label}: ))} ); }; ================================================ FILE: apps/builder/app/builder/shared/relative-time.tsx ================================================ import useRelativeTime from "@nkzw/use-relative-time"; export const RelativeTime = ({ time }: { time: Date }) => { return useRelativeTime(time.getTime()); }; ================================================ FILE: apps/builder/app/builder/shared/style-source-actions.tsx ================================================ import { useState, useEffect } from "react"; import { atom, computed } from "nanostores"; import { useStore } from "@nanostores/react"; import { Dialog, DialogContent, DialogTitle, DialogClose, Button, Text, Flex, theme, InputField, toast, } from "@webstudio-is/design-system"; import type { Instance, StyleSource } from "@webstudio-is/sdk"; import { $styleSources, $styleSourceSelections, $styles, $selectedStyleSources, $selectedStyleState, } from "~/shared/nano-states"; import { deleteStyleSourceMutable, findUnusedTokens, deleteStyleSourcesMutable, validateAndRenameStyleSource, renameStyleSourceMutable, type RenameStyleSourceError, } from "~/shared/style-source-utils"; import { serverSyncStore } from "~/shared/sync/sync-stores"; import { $selectedInstance } from "~/shared/awareness"; // Re-export the type for convenience export type { RenameStyleSourceError }; const $isDeleteUnusedTokensDialogOpen = atom(false); export const openDeleteUnusedTokensDialog = () => { $isDeleteUnusedTokensDialogOpen.set(true); }; export const $styleSourceUsages = computed( $styleSourceSelections, (styleSourceSelections) => { const styleSourceUsages = new Map>(); for (const { instanceId, values } of styleSourceSelections.values()) { for (const styleSourceId of values) { let usages = styleSourceUsages.get(styleSourceId); if (usages === undefined) { usages = new Set(); styleSourceUsages.set(styleSourceId, usages); } usages.add(instanceId); } } return styleSourceUsages; } ); const deselectMatchingStyleSource = (styleSourceId: StyleSource["id"]) => { const instanceId = $selectedInstance.get()?.id; if (instanceId === undefined) { return; } const selectedStyleSources = new Map($selectedStyleSources.get()); if (selectedStyleSources.get(instanceId) === styleSourceId) { selectedStyleSources.delete(instanceId); $selectedStyleSources.set(selectedStyleSources); $selectedStyleState.set(undefined); } }; export const deleteStyleSource = (styleSourceId: StyleSource["id"]) => { serverSyncStore.createTransaction( [$styleSources, $styleSourceSelections, $styles], (styleSources, styleSourceSelections, styles) => { deleteStyleSourceMutable({ styleSourceId, styleSources, styleSourceSelections, styles, }); } ); // reset selected style source if necessary deselectMatchingStyleSource(styleSourceId); }; export const deleteUnusedTokens = () => { const styleSources = $styleSources.get(); const styleSourceUsages = $styleSourceUsages.get(); const unusedTokenIds = findUnusedTokens({ styleSources, styleSourceUsages }); if (unusedTokenIds.length === 0) { return 0; } serverSyncStore.createTransaction( [$styleSources, $styleSourceSelections, $styles], (styleSources, styleSourceSelections, styles) => { deleteStyleSourcesMutable({ styleSourceIds: unusedTokenIds, styleSources, styleSourceSelections, styles, }); } ); return unusedTokenIds.length; }; export const renameStyleSource = ( id: StyleSource["id"], name: string ): RenameStyleSourceError | undefined => { const styleSources = $styleSources.get(); const validationError = validateAndRenameStyleSource({ id, name, styleSources, }); if (validationError) { return validationError; } serverSyncStore.createTransaction([$styleSources], (styleSources) => { renameStyleSourceMutable({ id, name, styleSources }); }); }; type DeleteStyleSourceDialogProps = { styleSource?: { id: StyleSource["id"]; name: string }; onClose: () => void; onConfirm: (styleSourceId: StyleSource["id"]) => void; }; export const DeleteStyleSourceDialog = ({ styleSource, onClose, onConfirm, }: DeleteStyleSourceDialogProps) => { return ( { if (isOpen === false) { onClose(); } }} > { // Prevent command panel from handling keyboard events event.stopPropagation(); }} > Delete confirmation {`Delete "${styleSource?.name}" token from the project including all of its styles?`} ); }; type RenameStyleSourceDialogProps = { styleSource?: { id: StyleSource["id"]; name: string }; onClose: () => void; onConfirm: (styleSourceId: StyleSource["id"], newName: string) => void; }; export const RenameStyleSourceDialog = ({ styleSource, onClose, onConfirm, }: RenameStyleSourceDialogProps) => { const [name, setName] = useState(""); const [error, setError] = useState(); // Reset name and clear error when styleSource changes useEffect(() => { if (styleSource?.name !== undefined) { setName(styleSource.name); setError(undefined); } }, [styleSource?.id, styleSource?.name]); const handleConfirm = () => { const renameError = renameStyleSource(styleSource!.id, name); if (renameError) { if (renameError.type === "minlength") { setError("Token name cannot be empty"); } else if (renameError.type === "duplicate") { setError("A token with this name already exists"); } return; } onConfirm(styleSource!.id, name); onClose(); }; return ( { if (isOpen === false) { onClose(); } }} > { // Prevent command panel from handling keyboard events event.stopPropagation(); if (event.key === "Enter" && !error) { handleConfirm(); } }} > Rename token { setName(event.target.value); setError(undefined); }} color={error ? "error" : undefined} /> {error && ( {error} )} ); }; export const DeleteUnusedTokensDialog = () => { const open = useStore($isDeleteUnusedTokensDialogOpen); const styleSourceUsages = useStore($styleSourceUsages); const styleSources = useStore($styleSources); const handleClose = () => { $isDeleteUnusedTokensDialogOpen.set(false); }; const unusedTokenIds = findUnusedTokens({ styleSources, styleSourceUsages }); const unusedTokens: Array<{ id: string; name: string }> = unusedTokenIds .map((id) => { const styleSource = styleSources.get(id); return styleSource?.type === "token" ? { id, name: styleSource.name } : null; }) .filter((token): token is { id: string; name: string } => token !== null); return ( { if (isOpen === false) { handleClose(); } }} > { event.stopPropagation(); }} > Delete unused tokens {unusedTokens.length === 0 ? ( There are no unused tokens to delete. ) : ( <> Delete {unusedTokens.length} unused{" "} {unusedTokens.length === 1 ? "token" : "tokens"} from the project? {unusedTokens.map((token) => token.name).join(", ")} )} {unusedTokens.length > 0 && ( )} ); }; ================================================ FILE: apps/builder/app/builder/shared/topbar-layout.stories.tsx ================================================ import { Button, Flex, rawTheme, StorySection, theme, ToolbarButton, Text, buttonStyle, Link, } from "@webstudio-is/design-system"; import { CloudIcon, OfflineIcon, ShieldIcon, WebstudioIcon, } from "@webstudio-is/icons"; import { $queueStatus } from "~/shared/sync/project-queue"; import { $authPermit } from "~/shared/nano-states"; import { SyncStatus } from "~/builder/features/sync-status"; import { ViewMode } from "~/builder/features/view-mode"; import { TopbarLayout } from "./topbar-layout"; export default { title: "Topbar layout", component: TopbarLayout, }; const MenuPlaceholder = () => ( ); const PagePlaceholder = () => ( Home ); const BreakpointsPlaceholder = () => ( Base ); const SyncErrorIndicator = () => ( ); const ViewModeIndicator = () => ( ); const SafeModeIndicator = () => ( ); export const TopbarLayouts = () => { $queueStatus.set({ status: "failed" }); $authPermit.set("view"); return ( Default } left={} center={} right={ <> } /> With indicators } left={} center={} right={ <> Clone } /> Menu only } /> Sync failed } left={} center={} right={ <> } /> View mode } left={} center={} right={ <> } /> ); }; ================================================ FILE: apps/builder/app/builder/shared/topbar-layout.tsx ================================================ import type { ReactNode } from "react"; import { theme, css, Flex, Toolbar, ToolbarToggleGroup, type CSS, } from "@webstudio-is/design-system"; const topbarContainerStyle = css({ position: "relative", display: "flex", justifyContent: "space-between", background: theme.colors.backgroundTopbar, height: theme.spacing[15], paddingRight: theme.panel.paddingInline, color: theme.colors.foregroundContrastMain, }); type TopbarLayoutProps = { menu: ReactNode; left?: ReactNode; center?: ReactNode; right?: ReactNode; loading?: ReactNode; css?: CSS; }; export const TopbarLayout = ({ menu, left, center, right, loading, css, }: TopbarLayoutProps) => ( ); ================================================ FILE: apps/builder/app/builder/shared/topbar.tsx ================================================ import { useStore } from "@nanostores/react"; import { theme, ToolbarButton, Text, type CSS, Tooltip, Kbd, } from "@webstudio-is/design-system"; import type { Project } from "@webstudio-is/project"; import { $pages } from "~/shared/sync/data-stores"; import { $editingPageId } from "~/shared/nano-states"; import { ShareButton } from "~/builder/features/share"; import { PublishButton } from "~/builder/features/publish"; import { SyncStatus } from "~/builder/features/sync-status"; import { Menu } from "~/builder/features/menu"; import { BreakpointsContainer } from "~/builder/features/breakpoints"; import { ViewMode } from "~/builder/features/view-mode"; import { AddressBarPopover } from "~/builder/features/address-bar"; import { toggleActiveSidebarPanel } from "~/builder/shared/nano-states"; import type { ReactNode } from "react"; import { CloneButton } from "~/builder/features/clone"; import { $selectedPage } from "~/shared/awareness"; import { BuilderModeDropDown } from "~/builder/features/builder-mode"; import { SafeModeButton } from "~/builder/features/safe-mode"; import { TopbarLayout } from "./topbar-layout"; const PagesButton = () => { const page = useStore($selectedPage); if (page === undefined) { return; } return ( {"Pages or page settings "} } > { $editingPageId.set(event.altKey ? page.id : undefined); toggleActiveSidebarPanel("pages"); }} tabIndex={0} > {page.name} ); }; type TopbarProps = { project: Project; loading: ReactNode; css: CSS; }; export const Topbar = ({ project, css, loading }: TopbarProps) => { const pages = useStore($pages); return ( } left={ pages ? ( <> ) : undefined } center={} right={ <> } loading={loading} /> ); }; ================================================ FILE: apps/builder/app/builder/shared/url-pattern.test.ts ================================================ import { expect, test, describe } from "vitest"; import { compilePathnamePattern, isPathnamePattern, matchPathnamePattern, tokenizePathnamePattern, validatePathnamePattern, } from "./url-pattern"; import { VALID_URLPATTERN_PATHS } from "@webstudio-is/sdk/router-paths.test"; /** * These tests use the shared test data from @webstudio-is/sdk to ensure * URLPattern matching is consistent with schema validation. */ describe("Shared router path tests - URLPattern matching", () => { describe("all valid paths can be used as URLPattern patterns", () => { test.each(VALID_URLPATTERN_PATHS)("accepts pattern: %s", (pattern) => { // Pattern should be valid for URLPattern // Some patterns may have validation errors due to our custom rules // but they should still work with URLPattern itself expect(() => matchPathnamePattern(pattern, pattern)).not.toThrow(); }); }); describe("all valid static paths can be matched exactly", () => { // Filter out patterns (those with : or *) const staticPaths = VALID_URLPATTERN_PATHS.filter( (p) => !p.includes(":") && !p.includes("*") ); test.each(staticPaths)("matches exactly: %s", (path) => { const result = matchPathnamePattern(path, path); expect(result).toEqual({}); }); }); }); test("decode matched params", () => { expect(matchPathnamePattern("/blog/:slug", "/blog/привет")).toEqual({ slug: "привет", }); expect( matchPathnamePattern( "/blog/:slug", "/blog/%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82" ) ).toEqual({ slug: "привет", }); }); describe("URLPattern Unicode/non-Latin character support", () => { // These tests verify that the URLPattern-based router properly handles // non-Latin characters in redirect paths, which is important for // international websites (Chinese, Japanese, Korean, etc.) describe("exact path matching with non-Latin characters", () => { test("matches Chinese Simplified paths", () => { expect(matchPathnamePattern("/关于我们", "/关于我们")).toEqual({}); expect(matchPathnamePattern("/产品/手机", "/产品/手机")).toEqual({}); }); test("matches Chinese Traditional paths", () => { expect(matchPathnamePattern("/關於我們", "/關於我們")).toEqual({}); expect(matchPathnamePattern("/港聞", "/港聞")).toEqual({}); }); test("matches Japanese paths (Hiragana, Katakana, Kanji)", () => { expect(matchPathnamePattern("/こんにちは", "/こんにちは")).toEqual({}); expect(matchPathnamePattern("/カテゴリ", "/カテゴリ")).toEqual({}); expect(matchPathnamePattern("/日本語", "/日本語")).toEqual({}); }); test("matches Korean paths (Hangul)", () => { expect(matchPathnamePattern("/한국어", "/한국어")).toEqual({}); expect(matchPathnamePattern("/블로그/포스트", "/블로그/포스트")).toEqual( {} ); }); test("matches Cyrillic paths", () => { expect(matchPathnamePattern("/о-нас", "/о-нас")).toEqual({}); expect(matchPathnamePattern("/блог/статья", "/блог/статья")).toEqual({}); }); test("matches Arabic paths", () => { expect(matchPathnamePattern("/مرحبا", "/مرحبا")).toEqual({}); }); test("matches Hebrew paths", () => { expect(matchPathnamePattern("/שלום", "/שלום")).toEqual({}); }); test("matches Greek paths", () => { expect(matchPathnamePattern("/σχετικά", "/σχετικά")).toEqual({}); }); test("matches European diacritics", () => { expect(matchPathnamePattern("/über-uns", "/über-uns")).toEqual({}); expect(matchPathnamePattern("/café", "/café")).toEqual({}); expect(matchPathnamePattern("/niño", "/niño")).toEqual({}); }); }); describe("dynamic segments with non-Latin characters", () => { test("captures Chinese characters in :slug parameter", () => { expect(matchPathnamePattern("/:slug", "/关于我们")).toEqual({ slug: "关于我们", }); }); test("captures Japanese characters in :slug parameter", () => { expect(matchPathnamePattern("/blog/:slug", "/blog/日本語")).toEqual({ slug: "日本語", }); }); test("captures Korean characters in :slug parameter", () => { expect( matchPathnamePattern("/:category/:post", "/블로그/포스트") ).toEqual({ category: "블로그", post: "포스트", }); }); }); describe("wildcard patterns with non-Latin characters", () => { test("matches wildcard with Chinese paths", () => { expect(matchPathnamePattern("/blog/*", "/blog/中文/测试")).toEqual({ 0: "中文/测试", }); }); test("matches wildcard with Japanese paths", () => { expect( matchPathnamePattern("/カテゴリ/*", "/カテゴリ/記事/詳細") ).toEqual({ 0: "記事/詳細", }); }); }); describe("URL-encoded vs literal matching", () => { test("matches URL-encoded paths and decodes them", () => { // %E6%B8%AF%E8%81%9E is URL-encoded 港聞 expect(matchPathnamePattern("/:slug", "/%E6%B8%AF%E8%81%9E")).toEqual({ slug: "港聞", }); }); test("matches mixed Latin and non-Latin paths", () => { expect(matchPathnamePattern("/blog/关于", "/blog/关于")).toEqual({}); expect(matchPathnamePattern("/news/:slug", "/news/港聞")).toEqual({ slug: "港聞", }); }); }); }); test("check pathname is pattern", () => { expect(isPathnamePattern("/:name")).toEqual(true); expect(isPathnamePattern("/:slug*")).toEqual(true); expect(isPathnamePattern("/:id?")).toEqual(true); expect(isPathnamePattern("/*")).toEqual(true); expect(isPathnamePattern("")).toEqual(false); expect(isPathnamePattern("/")).toEqual(false); expect(isPathnamePattern("/blog")).toEqual(false); expect(isPathnamePattern("/blog/post-name")).toEqual(false); }); test("tokenize named params in pathname pattern", () => { expect(tokenizePathnamePattern("/blog/:id")).toEqual([ { type: "fragment", value: "/blog/" }, { type: "param", name: "id", optional: false, splat: false }, ]); expect(tokenizePathnamePattern("/blog/:slug*")).toEqual([ { type: "fragment", value: "/blog/" }, { type: "param", name: "slug", optional: false, splat: true }, ]); expect(tokenizePathnamePattern("/blog/:name?")).toEqual([ { type: "fragment", value: "/blog/" }, { type: "param", name: "name", optional: true, splat: false }, ]); expect(tokenizePathnamePattern("/blog/*")).toEqual([ { type: "fragment", value: "/blog/" }, { type: "param", name: "0", optional: false, splat: true }, ]); }); test("tokenize complex pathname pattern", () => { expect(tokenizePathnamePattern("/blog/:date/:slug*")).toEqual([ { type: "fragment", value: "/blog/" }, { type: "param", name: "date", optional: false, splat: false }, { type: "fragment", value: "/" }, { type: "param", name: "slug", optional: false, splat: true }, ]); }); test("tokenize trailing fragment", () => { expect(tokenizePathnamePattern("/blog/:name/profile")).toEqual([ { type: "fragment", value: "/blog/" }, { type: "param", name: "name", optional: false, splat: false }, { type: "fragment", value: "/profile" }, ]); }); test("tokenize pathname without params", () => { expect(tokenizePathnamePattern("/blog/post")).toEqual([ { type: "fragment", value: "/blog/post" }, ]); }); test("tokenize empty pathname", () => { expect(tokenizePathnamePattern("")).toEqual([ { type: "fragment", value: "" }, ]); }); test("compile pathname pattern with named values", () => { expect( compilePathnamePattern(tokenizePathnamePattern("/blog/:id/:date"), { id: "my-id", date: "my-date", }) ).toEqual("/blog/my-id/my-date"); }); test("compile pathname pattern with named values", () => { expect( compilePathnamePattern(tokenizePathnamePattern("/blog/:id/:date"), { id: "my-id", date: "my-date", }) ).toEqual("/blog/my-id/my-date"); }); test("compile pathname pattern with named wildcard", () => { expect( compilePathnamePattern(tokenizePathnamePattern("/blog/:slug*/:date*"), { slug: "my-slug", date: "my-date", }) ).toEqual("/blog/my-slug/my-date"); }); test("compile pathname pattern with indexed wildcard", () => { expect( compilePathnamePattern(tokenizePathnamePattern("/blog/*/:slug*/*"), { // use random order 1: "one", slug: "my-slug", 0: "zero", }) ).toEqual("/blog/zero/my-slug/one"); }); test("compile pathname pattern with many indexed wildcards (more than 10)", () => { expect( compilePathnamePattern( tokenizePathnamePattern("/blog/*/*/*/*/*/*/*/*/*/*/*/*"), { // use random order 11: "eleven", 10: "ten", 9: "nine", 8: "eight", 7: "seven", 6: "six", 5: "five", 4: "four", 3: "three", 2: "two", 1: "one", 0: "zero", } ) ).toEqual( "/blog/zero/one/two/three/four/five/six/seven/eight/nine/ten/eleven" ); }); test("collapse empty values", () => { expect( compilePathnamePattern(tokenizePathnamePattern("/blog/*/:slug"), { // use random order slug: "", 0: "", }) ).toEqual("/blog//"); }); test("collapse optional values with preceding slash", () => { expect( compilePathnamePattern(tokenizePathnamePattern("/blog/:slug?/:name?"), { slug: "", }) ).toEqual("/blog"); }); test("validate invalid pattern", () => { expect(validatePathnamePattern("/:name*?")).toEqual([ `Invalid path pattern '/:name*?'`, ]); }); test("validate named groups", () => { expect(validatePathnamePattern("/:name")).toEqual([]); expect(validatePathnamePattern("/:name/last")).toEqual([]); }); test("validate named groups with optional modifier", () => { expect(validatePathnamePattern("/:name?")).toEqual([]); expect(validatePathnamePattern("/:name?/last")).toEqual([]); }); test('validate "one or more" named group modifier', () => { expect(validatePathnamePattern("/:name+/:slug+")).toEqual([ "Dynamic parameters ':name+', ':slug+' shouldn't have the + modifier.", ]); }); test('validate "zero or more" named group modifier', () => { expect(validatePathnamePattern("/:name*")).toEqual([]); expect(validatePathnamePattern("/:name*/:another*/:slug*")).toEqual([ "':name*', ':another*' should end the path.", ]); }); test("validate wildcard groups", () => { expect(validatePathnamePattern("/*")).toEqual([]); expect(validatePathnamePattern("/*?/*?")).toEqual([ `Optional wildcard '*?' is not allowed.`, ]); expect(validatePathnamePattern("/*/*")).toEqual([ `Wildcard '*' should end the path.`, ]); expect(validatePathnamePattern("/*/last")).toEqual([ `Wildcard '*' should end the path.`, ]); expect(validatePathnamePattern("/*?/*/last")).toEqual([ `Optional wildcard '*?' is not allowed.`, `Wildcard '*' should end the path.`, ]); }); test("forbid wildcard group with static parts before", () => { expect(validatePathnamePattern("/blog-*")).toEqual([ `Static parts cannot be mixed with dynamic parameters at 'blog-*'.`, ]); }); test(`forbid named group with "zero or more" modifier and static parts before`, () => { expect(validatePathnamePattern("/blog-:slug*")).toEqual([ `Static parts cannot be mixed with dynamic parameters at 'blog-:slug*'.`, ]); }); test(`forbid named group with static parts before or after`, () => { expect(validatePathnamePattern("/prefix-:id")).toEqual([ `Static parts cannot be mixed with dynamic parameters at 'prefix-:id'.`, ]); expect(validatePathnamePattern("/:id-suffix")).toEqual([ `Static parts cannot be mixed with dynamic parameters at ':id-suffix'.`, ]); expect(validatePathnamePattern("/prefix-:id-suffix")).toEqual([ `Static parts cannot be mixed with dynamic parameters at 'prefix-:id-suffix'.`, ]); }); test(`? should be allowed in named groups only`, () => { expect(validatePathnamePattern("/name?")).toEqual([ `Optional parameter indicator ? must be at the end of the named parameter. Correct usage: /:param?`, ]); }); ================================================ FILE: apps/builder/app/builder/shared/url-pattern.ts ================================================ import { matchPathnameParams } from "@webstudio-is/sdk"; import { URLPattern } from "urlpattern-polyfill"; export { isPathnamePattern } from "@webstudio-is/sdk"; const baseUrl = "http://url"; const tryDecode = (encoded: string) => { try { return decodeURIComponent(encoded); } catch { return encoded; } }; export const matchPathnamePattern = (pattern: string, pathname: string) => { try { const groups = new URLPattern({ pathname: pattern }).exec({ pathname }) ?.pathname.groups; if (groups) { const decodedGroups: Record = {}; for (const [name, value] of Object.entries(groups)) { if (value) { decodedGroups[name] = tryDecode(value); } } return decodedGroups; } } catch { // empty block } }; // allowed syntax // :name - group without modifiers // :name? - group with optional modifier // :name* - group with zero or more modifier in the end // * - wildcard group in the end type Token = | { type: "fragment"; value: string } | { type: "param"; name: string; optional: boolean; splat: boolean }; export const tokenizePathnamePattern = (pathname: string) => { const tokens: Token[] = []; let lastCursor = 0; let lastWildcard = -1; for (const match of matchPathnameParams(pathname)) { const cursor = match.index ?? 0; if (lastCursor < cursor) { tokens.push({ type: "fragment", value: pathname.slice(lastCursor, cursor), }); } lastCursor = cursor + match[0].length; if (match.groups?.name) { const optional = match.groups.modifier === "?"; const splat = match.groups.modifier === "*"; tokens.push({ type: "param", name: match.groups.name, optional, splat }); } if (match.groups?.wildcard) { lastWildcard += 1; tokens.push({ type: "param", name: lastWildcard.toString(), splat: true, optional: false, }); } } if (lastCursor < pathname.length || tokens.length === 0) { tokens.push({ type: "fragment", value: pathname.slice(lastCursor), }); } return tokens; }; export const compilePathnamePattern = ( tokens: Token[], values: Record ) => { let compiledPathname = ""; for (const token of tokens) { if (token.type === "fragment") { compiledPathname += token.value; } if (token.type === "param") { const value = values[token.name] ?? ""; // remove preceding slash if (token.optional && value.length === 0) { compiledPathname = compiledPathname.slice(0, -1); } compiledPathname += value; } } return compiledPathname; }; export const validatePathnamePattern = (pathname: string) => { try { new URLPattern(pathname, baseUrl); } catch { return [`Invalid path pattern '${pathname}'`]; } const messages: string[] = []; // fobid :name+ everywhere const namedGroupsWithPlus = Array.from(pathname.matchAll(/:\w+\+/g)).flat(); if (namedGroupsWithPlus.length > 0) { const list = namedGroupsWithPlus.map((item) => `'${item}'`).join(", "); messages.push(`Dynamic parameters ${list} shouldn't have the + modifier.`); } // :name* in the middle const namedGroupsWithAsterisk = Array.from( // skip matching in the end of string pathname.matchAll(/:\w+\*(?!$)/g) ).flat(); if (namedGroupsWithAsterisk.length > 0) { const list = namedGroupsWithAsterisk.map((item) => `'${item}'`).join(", "); messages.push(`${list} should end the path.`); } // *? everywhere const wildcardGroupsWithQuestion = Array.from( pathname.matchAll(/\*\?/g) ).flat(); if (wildcardGroupsWithQuestion.length > 0) { messages.push(`Optional wildcard '*?' is not allowed.`); } // * in the middle const wildcardGroups = Array.from( // skip matching with :name before * // skip matching with ? after * // skip matching with * in the end pathname.matchAll(/(? 0) { messages.push(`Wildcard '*' should end the path.`); } // show segment errors only when syntax is valid if (messages.length > 0) { return messages; } for (const segment of pathname.split("/")) { const group = segment.match(/(?:\w+(\*|\?)?)/)?.groups?.group; if (group) { if (group.length !== segment.length) { messages.push( `Static parts cannot be mixed with dynamic parameters at '${segment}'.` ); } } else if (segment.includes("*")) { if (segment.length > 1) { messages.push( `Static parts cannot be mixed with dynamic parameters at '${segment}'.` ); } } else if (segment.includes("?")) { messages.push( `Optional parameter indicator ? must be at the end of the named parameter. Correct usage: /:param?` ); } } return messages; }; ================================================ FILE: apps/builder/app/builder/shared/use-disable-context-menu.ts ================================================ import { useEffect } from "react"; /** * Disables the default browser context menu throughout the application, * except for interactive elements like links, inputs, textareas, etc. * * Users expect to see Webstudio's custom context menu when right-clicking, * not the browser's default menu. This hook prevents confusion by ensuring * only the application's context menu appears, while still allowing the * browser menu for interactive elements where it's useful (e.g., copy/paste * in inputs, open link in new tab). */ export const useDisableContextMenu = () => { useEffect(() => { const handleContextMenu = (event: MouseEvent) => { const target = event.target as HTMLElement; // Allow context menu for interactive elements const interactiveSelectors = [ "input", "textarea", "select", "a[href]", '[contenteditable="true"]', "pre", ]; const isInteractive = interactiveSelectors.some((selector) => target.closest(selector) ); if (!isInteractive) { event.preventDefault(); } }; document.addEventListener("contextmenu", handleContextMenu); return () => { document.removeEventListener("contextmenu", handleContextMenu); }; }, []); }; ================================================ FILE: apps/builder/app/builder/sidebar-left/sidebar-left.tsx ================================================ import { useRef, useState, type ReactNode } from "react"; import { Kbd, rawTheme, Text } from "@webstudio-is/design-system"; import { useSubscribe, type Publish } from "~/shared/pubsub"; import { $dragAndDropState, $isContentMode, $isPreviewMode, } from "~/shared/nano-states"; import { Flex } from "@webstudio-is/design-system"; import { theme } from "@webstudio-is/design-system"; import { ExtensionIcon, HelpIcon, ImageIcon, NavigatorIcon, PageIcon, PlusIcon, type IconComponent, } from "@webstudio-is/icons"; import { HelpCenter } from "../features/help/help-center"; import { useStore } from "@nanostores/react"; import { $activeSidebarPanel, setActiveSidebarPanel, toggleActiveSidebarPanel, } from "~/builder/shared/nano-states"; import { SidebarButton, SidebarTabs, SidebarTabsContent, SidebarTabsList, SidebarTabsTrigger, } from "./sidebar-tabs"; import { ExternalDragDropMonitor, POTENTIAL, isBlockedByBackdrop, useOnDropEffect, useExternalDragStateEffect, } from "~/builder/shared/assets/drag-monitor"; import { getSetting, setSetting } from "~/builder/shared/client-settings"; import { ComponentsPanel } from "~/builder/features/components"; import { PagesPanel } from "~/builder/features/pages"; import { NavigatorPanel } from "~/builder/features/navigator"; import { AssetsPanel } from "~/builder/features/assets"; import { MarketplacePanel } from "~/builder/features/marketplace"; import type { SidebarPanelName } from "./types"; const none = { Panel: () => null }; const HelpTabTrigger = () => { const [helpIsOpen, setHelpIsOpen] = useState(false); return ( ); }; type PanelConfig = { name: SidebarPanelName; label: ReactNode; Icon: IconComponent; Panel: (props: { publish: Publish; onClose: () => void }) => ReactNode; visibility?: { content?: boolean; // if set, controls visibility in edit mode, if not the panel is visible // Probably other modes }; }; const isPanelVisible = ( panel: Pick, { isPreviewMode, isContentMode, }: { isPreviewMode: boolean; isContentMode: boolean } ) => { if (isPreviewMode) { return false; } const { visibility } = panel; // If visibility is not defined, the panel is always visible if (visibility === undefined) { return true; } if (isContentMode) { // If visibility.edit is not defined, the panel is visible return visibility.content ?? true; } return true; }; const panels: PanelConfig[] = [ { name: "components", label: ( Components   ), Icon: PlusIcon, Panel: ComponentsPanel, visibility: { content: false, }, }, { name: "pages", label: "Pages", Icon: PageIcon, Panel: PagesPanel, }, { name: "navigator", label: ( Navigator   ), Icon: NavigatorIcon, Panel: NavigatorPanel, }, { name: "assets", label: "Assets", Icon: ImageIcon, Panel: AssetsPanel, }, { name: "marketplace", label: "Marketplace", Icon: ExtensionIcon, Panel: MarketplacePanel, visibility: { content: false, }, }, ]; const setSidebarPanelWidth = (panelName: string, width: number) => { const widths = getSetting("sidebarPanelWidths"); setSetting("sidebarPanelWidths", { ...widths, [panelName]: width }); }; const getSidebarPanelWidth = (panelName: SidebarPanelName) => { if (panelName === "none") { return theme.sizes.sidebarWidth; } const width = getSetting("sidebarPanelWidths")[panelName]; if (width === undefined) { return theme.sizes.sidebarWidth; } return width + "px"; }; type SidebarLeftProps = { publish: Publish; }; export const SidebarLeft = ({ publish }: SidebarLeftProps) => { const activePanel = useStore($activeSidebarPanel); const dragAndDropState = useStore($dragAndDropState); const { Panel } = panels.find((item) => item.name === activePanel) ?? none; const isPreviewMode = useStore($isPreviewMode); const isContentMode = useStore($isContentMode); const tabsWrapperRef = useRef(null); const returnTabRef = useRef(undefined); useSubscribe("dragEnd", () => { setActiveSidebarPanel("auto"); }); useOnDropEffect(() => { const element = tabsWrapperRef.current; if (element == null) { return; } if (isBlockedByBackdrop(element)) { return; } returnTabRef.current = undefined; }); useExternalDragStateEffect((state) => { if (state !== POTENTIAL) { if (returnTabRef.current !== undefined) { setActiveSidebarPanel(returnTabRef.current); } returnTabRef.current = undefined; return; } const element = tabsWrapperRef.current; if (element == null) { return; } if (isBlockedByBackdrop(element)) { return; } returnTabRef.current = activePanel; // Save prevous state setActiveSidebarPanel("assets"); }); const modes = { isContentMode, isPreviewMode }; return ( { // In preview mode, we don't show left sidebar, but we want to allow pages panel to be open in the preview mode. // This way user can switch pages without exiting preview mode. } {isPreviewMode === false && (
{panels .filter((panel) => isPanelVisible(panel, modes)) .map(({ name, Icon, label }) => { return ( { toggleActiveSidebarPanel(name); }} > ); })}
)} { if (activePanel !== "none") { setSidebarPanelWidth(activePanel, width); } }} onKeyDown={(event) => { if (event.key === "Escape") { setActiveSidebarPanel("none"); } }} resizable css={{ "--sidebar-left-panel-width": `${getSidebarPanelWidth(activePanel)}`, width: "var(--sidebar-left-panel-width)", minWidth: theme.sizes.sidebarWidth, maxWidth: theme.spacing[35], // We need the node to be rendered but hidden // to keep receiving the drag events. visibility: dragAndDropState.isDragging && dragAndDropState.dragPayload?.origin === "panel" && getSetting("navigatorLayout") !== "undocked" ? "hidden" : "visible", }} > setActiveSidebarPanel("none")} />
); }; ================================================ FILE: apps/builder/app/builder/sidebar-left/sidebar-tabs.tsx ================================================ import { Box, Tabs, TabsContent, TabsList, TabsTrigger, Tooltip, css, focusRingStyle, styled, theme, useResize, type CSS, } from "@webstudio-is/design-system"; import { forwardRef, useEffect, useRef, type ComponentProps, type ReactNode, } from "react"; export const SidebarTabs = styled(Tabs, { display: "flex", flexDirection: "column", alignItems: "center", position: "relative", boxSizing: "border-box", flexGrow: 1, }); const triggerFocusRing = focusRingStyle(); const buttonStyle = css({ position: "relative", boxSizing: "border-box", flexShrink: 0, display: "flex", size: theme.spacing[15], m: 0, userSelect: "none", outline: "none", alignItems: "center", justifyContent: "center", color: theme.colors.foregroundIconMain, backgroundColor: theme.colors.backgroundPanel, border: "none", "&:focus-visible": triggerFocusRing, "@hover": { "&:hover": { backgroundColor: theme.colors.backgroundHover, }, }, '&[data-state="active"]': { backgroundColor: theme.colors.backgroundHover, }, }); export const SidebarButton = forwardRef< HTMLButtonElement, ComponentProps<"button"> & { label: string } >(({ label, ...props }, ref) => { return ( ); }); export const SidebarTabsTrigger = forwardRef< HTMLButtonElement, ComponentProps & { label: ReactNode | string } >(({ label, children, ...props }, ref) => { return ( {children} ); }); export const SidebarTabsList = styled(TabsList, { boxSizing: "border-box", flexShrink: 0, display: "flex", flexDirection: "column", alignItems: "center", outline: "none", flexGrow: 1, backgroundColor: theme.colors.backgroundPanel, }); const sidebarTabsContentStyle = css({ flexGrow: 1, position: "absolute", top: 0, left: "100%", height: "100%", backgroundColor: theme.colors.backgroundPanel, outline: "none", // Drawing border this way to ensure content still has full width, avoid subpixels and give layout round numbers "&::after": { content: "''", position: "absolute", top: 0, right: 0, bottom: 0, width: 1, background: theme.colors.borderMain, }, variants: { resizable: { true: { overflow: "auto", resize: "horizontal", }, }, }, }); type SidebarTabsContentProps = Omit< ComponentProps, "onResize" > & { css?: CSS; resizable?: boolean; onResize?: (size: { width: number; height: number }) => void; }; export const SidebarTabsContent = ({ resizable, css, onResize, ...props }: SidebarTabsContentProps) => { const onResizeRef = useRef(onResize); onResizeRef.current = onResize; const [element, setElement] = useResize({ onResizeEnd: (entries) => { if (entries[0] && onResizeRef.current) { onResizeRef.current({ width: entries[0].contentRect.width, height: entries[0].contentRect.height, }); } element?.style.removeProperty("width"); }, }); useEffect(() => { if (element && onResizeRef.current) { const rect = element.getBoundingClientRect(); onResizeRef.current({ width: rect.width, height: rect.height }); } }, [element]); return ( ); }; ================================================ FILE: apps/builder/app/builder/sidebar-left/types.ts ================================================ export const sidebarPanelNames = [ "assets", "components", "navigator", "pages", "marketplace", ] as const; export type SidebarPanelName = (typeof sidebarPanelNames)[number] | "none"; ================================================ FILE: apps/builder/app/canvas/canvas.tsx ================================================ import { useMemo, useEffect, useState, useLayoutEffect, useRef } from "react"; import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; import { useStore } from "@nanostores/react"; import { type Instances, coreMetas } from "@webstudio-is/sdk"; import { coreTemplates } from "@webstudio-is/sdk/core-templates"; import type { Components } from "@webstudio-is/react-sdk"; import { wsImageLoader, wsVideoLoader } from "@webstudio-is/image"; import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime"; import * as baseComponents from "@webstudio-is/sdk-components-react"; import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas"; import { hooks as baseComponentHooks } from "@webstudio-is/sdk-components-react/hooks"; import * as baseComponentTemplates from "@webstudio-is/sdk-components-react/templates"; import * as animationComponents from "@webstudio-is/sdk-components-animation"; import * as animationComponentMetas from "@webstudio-is/sdk-components-animation/metas"; import * as animationTemplates from "@webstudio-is/sdk-components-animation/templates"; import { hooks as animationComponentHooks } from "@webstudio-is/sdk-components-animation/hooks"; import * as radixComponents from "@webstudio-is/sdk-components-react-radix"; import * as radixComponentMetas from "@webstudio-is/sdk-components-react-radix/metas"; import * as radixTemplates from "@webstudio-is/sdk-components-react-radix/templates"; import { hooks as radixComponentHooks } from "@webstudio-is/sdk-components-react-radix/hooks"; import { ErrorMessage } from "~/shared/error"; import { $publisher, publish } from "~/shared/pubsub"; import { registerContainers, serverSyncStore, useCanvasStore, } from "~/shared/sync/sync-stores"; import { GlobalStyles, subscribeStyles, mountStyles, manageDesignModeStyles, manageContentEditModeStyles, } from "./shared/styles"; import { WebstudioComponentCanvas, WebstudioComponentPreview, } from "./features/webstudio-component"; import { $assets, $pages, $instances, registerComponentLibrary, $registeredComponents, subscribeComponentHooks, $isPreviewMode, $isDesignMode, $isContentMode, subscribeModifierKeys, assetBaseUrl, $breakpoints, } from "~/shared/nano-states"; import { useDragAndDrop } from "./shared/use-drag-drop"; import { initCopyPaste, initCopyPasteForContentEditMode, } from "~/shared/copy-paste/init-copy-paste"; import { inflateInstance, subscribeInflator } from "./inflator"; import { useWindowResizeDebounced } from "~/shared/dom-hooks"; import { subscribeInstanceSelection } from "./instance-selection"; import { subscribeInstanceHovering } from "./instance-hovering"; import { useHashLinkSync } from "~/shared/pages"; import { useMount } from "~/shared/hook-utils/use-mount"; import { subscribeInterceptedEvents } from "./interceptor"; import { subscribeCommands } from "~/canvas/shared/commands"; import { updateCollaborativeInstanceRect } from "./collaborative-instance"; import { initCanvasApi } from "~/shared/canvas-api"; import { subscribeFontLoadingDone } from "./shared/font-weight-support"; import { subscribeSelected } from "./instance-selected"; import { subscribeGridGuidesOnSelected } from "./grid-guide-utils"; import { subscribeScrollNewInstanceIntoView } from "./shared/scroll-new-instance-into-view"; import { $selectedPage } from "~/shared/awareness"; import { createInstanceElement } from "./elements"; import { subscribeScrollbarSize } from "./scrollbar-width"; import { compareMedia } from "@webstudio-is/css-engine"; import { builderApi } from "~/shared/builder-api"; import { useDebounceEffect } from "@webstudio-is/design-system"; import { subscribeInstanceContextMenu } from "./instance-context-menu"; registerContainers(); const FallbackComponent = ({ error, resetErrorBoundary }: FallbackProps) => { // try to recover from error when webstudio data is changed again useEffect(() => { return serverSyncStore.subscribe(resetErrorBoundary); }, [resetErrorBoundary]); return ( // body is required to prevent breaking collapsed instances logic ); }; const handleError = (error: unknown) => { if (error instanceof Error) { builderApi.toast.error(error.message); return; } builderApi.toast.error(`Unknown error: ${String(error)}`); console.error(error); }; const useElementsTree = (components: Components, instances: Instances) => { const isSafeMode = builderApi.isSafeMode(); const page = useStore($selectedPage); const isPreviewMode = useStore($isPreviewMode); const breakpointsMap = useStore($breakpoints); const rootInstanceId = page?.rootInstanceId ?? ""; if (typeof window === "undefined") { // @todo remove after https://github.com/webstudio-is/webstudio/issues/1313 now its needed to be sure that no leaks exists console.info({ $assets: $assets.get().size, $pages: $pages.get()?.pages.length ?? 0, $instances: $instances.get().size, }); } const breakpoints = useMemo( () => [...breakpointsMap.values()].sort(compareMedia), [breakpointsMap] ); return useMemo(() => { return ( {createInstanceElement({ instances, instanceId: rootInstanceId, instanceSelector: [rootInstanceId], Component: isPreviewMode ? WebstudioComponentPreview : WebstudioComponentCanvas, components, })} ); }, [ instances, rootInstanceId, components, isPreviewMode, breakpoints, isSafeMode, ]); }; const DesignMode = () => { const debounceEffect = useDebounceEffect(); const ref = useRef(undefined); useDragAndDrop(); useEffect(() => { const abortController = new AbortController(); subscribeScrollNewInstanceIntoView( debounceEffect, ref, abortController.signal ); const unsubscribeSelected = subscribeSelected(debounceEffect); const unsubscribeGridGuides = subscribeGridGuidesOnSelected(); return () => { unsubscribeSelected(); unsubscribeGridGuides(); abortController.abort(); }; }, [debounceEffect]); useEffect(() => { const abortController = new AbortController(); const options = { signal: abortController.signal }; // We need to initialize this in both canvas and builder, // because the events will fire in either one, depending on where the focus is // @todo we need to forward the events from canvas to builder and avoid importing this // in both places initCopyPaste(options); manageDesignModeStyles(options); subscribeScrollbarSize(options); updateCollaborativeInstanceRect(options); subscribeInstanceSelection(options); subscribeInstanceHovering(options); subscribeFontLoadingDone(options); subscribeModifierKeys(options); return () => { abortController.abort(); }; }, []); return null; }; const ContentEditMode = () => { const debounceEffect = useDebounceEffect(); const ref = useRef(undefined); useEffect(() => { const abortController = new AbortController(); subscribeScrollNewInstanceIntoView( debounceEffect, ref, abortController.signal ); const unsubscribeSelected = subscribeSelected(debounceEffect); const unsubscribeGridGuides = subscribeGridGuidesOnSelected(); return () => { unsubscribeSelected(); unsubscribeGridGuides(); abortController.abort(); }; }, [debounceEffect]); useEffect(() => { const abortController = new AbortController(); const options = { signal: abortController.signal }; manageContentEditModeStyles(options); subscribeScrollbarSize(options); subscribeInstanceSelection(options); subscribeInstanceHovering(options); subscribeFontLoadingDone(options); initCopyPasteForContentEditMode(options); subscribeModifierKeys(options); return () => { abortController.abort(); }; }, []); return null; }; export const Canvas = () => { useCanvasStore(); const isDesignMode = useStore($isDesignMode); const isContentMode = useStore($isContentMode); useMount(() => { registerComponentLibrary({ components: {}, metas: coreMetas, templates: coreTemplates, }); registerComponentLibrary({ components: baseComponents, metas: baseComponentMetas, hooks: baseComponentHooks, templates: baseComponentTemplates, }); registerComponentLibrary({ namespace: "@webstudio-is/sdk-components-react-radix", components: radixComponents, metas: radixComponentMetas, hooks: radixComponentHooks, templates: radixTemplates, }); registerComponentLibrary({ namespace: "@webstudio-is/sdk-components-animation", components: animationComponents, metas: animationComponentMetas, hooks: animationComponentHooks, templates: animationTemplates, }); }); useMount(initCanvasApi); useLayoutEffect(() => { mountStyles(); }, []); useEffect(subscribeStyles, []); useEffect(subscribeComponentHooks, []); useEffect(subscribeCommands, []); useEffect(() => { $publisher.set({ publish }); }, []); const selectedPage = useStore($selectedPage); useEffect(() => { const rootInstanceId = selectedPage?.rootInstanceId; if (rootInstanceId !== undefined) { inflateInstance(rootInstanceId); } }); useWindowResizeDebounced(() => { const rootInstanceId = selectedPage?.rootInstanceId; if (rootInstanceId !== undefined) { inflateInstance(rootInstanceId); } }); useEffect(subscribeInflator, []); useHashLinkSync(); useEffect(subscribeInterceptedEvents, []); useEffect(subscribeInstanceContextMenu, []); const components = useStore($registeredComponents); const instances = useStore($instances); const elements = useElementsTree(components, instances); const [isInitialized, setInitialized] = useState(false); useEffect(() => { setInitialized(true); }, []); if (components.size === 0 || instances.size === 0) { return; } return ( <> {/* catch all errors in rendered components */} {elements} { // Call hooks after render to ensure effects are last. // Helps improve outline calculations as all styles are then applied. } {isDesignMode && isInitialized && } {isContentMode && isInitialized && } ); }; ================================================ FILE: apps/builder/app/canvas/collaborative-instance.ts ================================================ import { getAllElementsBoundingBox, getVisibleElementsByInstanceSelector, } from "~/shared/dom-utils"; import { $collaborativeInstanceSelector, $collaborativeInstanceRect, } from "~/shared/nano-states"; export const updateCollaborativeInstanceRect = ({ signal, }: { signal: AbortSignal; }) => { let frameHandler: number = -1; let elements: HTMLElement[] = []; const frameLoop = () => { const newRect = getAllElementsBoundingBox(elements); const prevRect = $collaborativeInstanceRect.get(); if ( newRect.x !== prevRect?.x || newRect.y !== prevRect?.y || newRect.width !== prevRect?.width || newRect.height !== prevRect?.height ) { $collaborativeInstanceRect.set(newRect); } frameHandler = requestAnimationFrame(frameLoop); }; const unsubscribe = $collaborativeInstanceSelector.subscribe((selector) => { if (selector === undefined) { cancelAnimationFrame(frameHandler); $collaborativeInstanceRect.set(undefined); elements = []; return; } elements = getVisibleElementsByInstanceSelector(selector); if (elements.length > 0) { cancelAnimationFrame(frameHandler); frameLoop(); } }); signal.addEventListener("abort", () => { unsubscribe(); cancelAnimationFrame(frameHandler); }); }; ================================================ FILE: apps/builder/app/canvas/elements.tsx ================================================ import { Fragment, type ForwardRefExoticComponent, type JSX, type RefAttributes, type RefObject, } from "react"; import type { Instance, Instances } from "@webstudio-is/sdk"; import type { Components } from "@webstudio-is/react-sdk"; import type { InstanceSelector } from "~/shared/tree-utils"; export type WebstudioComponentProps = { instance: Instance; instanceSelector: Instance["id"][]; components: Components; }; export const createInstanceElement = ({ instances, instanceId, instanceSelector, Component, components, ref, }: { instances: Instances; instanceId: Instance["id"]; instanceSelector: InstanceSelector; Component: ForwardRefExoticComponent< WebstudioComponentProps & RefAttributes >; components: Components; ref?: RefObject; }) => { const instance = instances.get(instanceId); if (instance === undefined) { return null; } return ( ); }; const renderText = (text: string): Array => { const lines = text.split("\n"); return lines.map((line, index) => ( {line} {index < lines.length - 1 &&
}
)); }; export const createInstanceChildrenElements = ({ instances, instanceSelector, children, Component, components, }: { instances: Instances; instanceSelector: InstanceSelector; children: Instance["children"][0][]; Component: ForwardRefExoticComponent< WebstudioComponentProps & RefAttributes >; components: Components; }) => { const elements = children.map((child) => { if (child.type === "text") { return renderText(child.value); } if (child.type === "expression") { return; } if (child.type === "id") { return createInstanceElement({ instances, instanceId: child.value, instanceSelector: [child.value, ...instanceSelector], Component, components, }); } child satisfies never; }); // let empty children be coalesced with fallback if (elements.length === 0) { return; } return elements; }; ================================================ FILE: apps/builder/app/canvas/features/build-mode/block-template.tsx ================================================ import { useStore } from "@nanostores/react"; import { selectorIdAttribute, type AnyComponent, type WebstudioComponentSystemProps, } from "@webstudio-is/react-sdk"; import * as React from "react"; import { $isDesignMode, $selectedInstanceSelector } from "~/shared/nano-states"; export const BlockTemplate = React.forwardRef< HTMLDivElement, WebstudioComponentSystemProps & { children: React.ReactNode } >(({ ...props }, ref) => { const isDesignMode = useStore($isDesignMode); const selectedInstanceSelector = useStore($selectedInstanceSelector); const templateInstanceStringSelector = props[selectorIdAttribute]; if (!isDesignMode) { return; } if (selectedInstanceSelector === undefined) { return; } if (selectedInstanceSelector.length === 0) { return; } const selectedSelector = selectedInstanceSelector.join(","); // Exclude all selected ancestors if (!selectedSelector.endsWith(templateInstanceStringSelector)) { return; } const childrenCount = React.Children.count(props.children); return (
); }) as AnyComponent; ================================================ FILE: apps/builder/app/canvas/features/build-mode/block.tsx ================================================ import { useStore } from "@nanostores/react"; import { blockTemplateComponent } from "@webstudio-is/sdk"; import { idAttribute, selectorIdAttribute, type AnyComponent, type WebstudioComponentSystemProps, } from "@webstudio-is/react-sdk"; import * as React from "react"; import { $instances, $isDesignMode, $isPreviewMode, $selectedInstanceSelector, } from "~/shared/nano-states"; export const Block = React.forwardRef< HTMLDivElement, { children: React.ReactNode } & WebstudioComponentSystemProps >(({ children, ...props }, ref) => { const instances = useStore($instances); const isDesignMode = useStore($isDesignMode); const isPreviewMode = useStore($isPreviewMode); const instanceId = props[idAttribute]; const instance = instances.get(instanceId); const selectedInstanceSelector = useStore($selectedInstanceSelector); const childArray = React.Children.toArray(children).filter((child) => React.isValidElement(child) ); if (instance === undefined) { return
Content Block instance is undefined
; } const templateInstanceId = instance.children.find( (child) => child.type === "id" && instances.get(child.value)?.component === blockTemplateComponent )?.value; if (templateInstanceId === undefined) { return
Content Block template child is not found
; } const templateInstance = instances.get(templateInstanceId); if (templateInstance === undefined) { return
Content Block template instance is not found
; } if (isDesignMode) { if (selectedInstanceSelector !== undefined) { const selectedSelector = selectedInstanceSelector.join(","); // If any template child is selected then render only template const stringSelector = props[selectorIdAttribute]; const templateSelector = `${templateInstanceId},${stringSelector}`; if (selectedSelector.endsWith(templateSelector)) { return (
{childArray.filter((child) => { const { instanceSelector } = child.props; return instanceSelector[0] === templateInstanceId; })}
); } } } const hasContent = childArray.length > 1; const hasTemplates = templateInstance.children.length > 0; if (!isDesignMode && !hasContent && !hasTemplates) { return <>; } const editableBlockStyle = hasContent ? { display: "contents" } : {}; return (
{childArray} {hasContent || isPreviewMode ? null : (
Editable block you can edit
)}
); }) as AnyComponent; ================================================ FILE: apps/builder/app/canvas/features/text-editor/index.ts ================================================ export { TextEditor } from "./text-editor"; ================================================ FILE: apps/builder/app/canvas/features/text-editor/interop.test.tsx ================================================ import { test, expect } from "vitest"; import { createHeadlessEditor } from "@lexical/headless"; import { LinkNode } from "@lexical/link"; import { $, renderData, renderTemplate, ws } from "@webstudio-is/template"; import { $convertToLexical, $convertToUpdates, type Refs } from "./interop"; const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="emptyBoxId"> <$.Box ws:id="textBoxId"> Hello{"\n"} <$.Bold ws:id="boldId"> <$.Italic ws:id="italicId">world {"\n"} <$.Span ws:id="spanId">and {"\n"} <$.RichTextLink ws:id="linkId" href="/my-url"> other realms Hello{"\n"} world {"\n"} and {"\n"} other realms ); const expectedState = { root: expect.objectContaining({ type: "root", children: [ expect.objectContaining({ type: "paragraph", children: [ expect.objectContaining({ type: "text", format: 0, style: "", text: "Hello", }), expect.objectContaining({ type: "linebreak" }), expect.objectContaining({ type: "text", format: 3, style: "", text: "world", }), expect.objectContaining({ type: "linebreak" }), expect.objectContaining({ type: "text", format: 0, style: "--style-node-trigger: 1;", text: "and", }), expect.objectContaining({ type: "linebreak" }), expect.objectContaining({ type: "link", format: "", rel: null, target: null, title: null, url: "", children: [ expect.objectContaining({ type: "text", format: 0, style: "", text: "other realms", }), ], }), ], }), ], }), }; test("convert legacy instances to lexical", async () => { const refs: Refs = new Map(); const editor = createHeadlessEditor({ nodes: [LinkNode], }); await new Promise((resolve) => { editor.update( () => { $convertToLexical(instances, "textBoxId", refs); }, { onUpdate: resolve } ); }); expect(editor.getEditorState().toJSON()).toEqual(expectedState); expect(refs).toEqual( new Map([ ["4:bold", "boldId"], ["4:italic", "italicId"], ["6:span", "spanId"], ["8", "linkId"], ]) ); }); test("convert element instances to lexical", async () => { const refs: Refs = new Map(); const editor = createHeadlessEditor({ nodes: [LinkNode], }); await new Promise((resolve) => { editor.update( () => { $convertToLexical(instances, "textElementId", refs); }, { onUpdate: resolve } ); }); expect(editor.getEditorState().toJSON()).toEqual(expectedState); expect(refs).toEqual( new Map([ ["13:bold", "boldElementId"], ["13:italic", "italicElementId"], ["15:span", "spanElementId"], ["17", "linkElementId"], ]) ); }); test("convert lexical to element instances updates", async () => { const refs: Refs = new Map(); const editor = createHeadlessEditor({ nodes: [LinkNode], }); await new Promise((resolve) => { editor.update( () => { $convertToLexical(instances, "textElementId", refs); }, { onUpdate: resolve } ); }); const treeRootInstance = instances.get("textElementId"); if (treeRootInstance === undefined) { throw Error("Tree root instance should be in test data"); } const updates = editor.getEditorState().read(() => { return $convertToUpdates(treeRootInstance, refs, new Map()); }); expect(updates).toEqual( renderTemplate( Hello{"\n"} world {"\n"} and {"\n"} other realms ).instances ); }); ================================================ FILE: apps/builder/app/canvas/features/text-editor/interop.ts ================================================ import { nanoid } from "nanoid"; import { type TextNode, type ElementNode, $getRoot, $createTextNode, $createParagraphNode, $createLineBreakNode, $isTextNode, $isElementNode, $isParagraphNode, $isLineBreakNode, } from "lexical"; import { $createLinkNode, $isLinkNode } from "@lexical/link"; import { elementComponent, type Instance, type Instances, } from "@webstudio-is/sdk"; import { $isSpanNode, $setNodeSpan } from "./toolbar-connector"; // Map export type Refs = Map; const legacyLexicalFormats = [ ["bold", "Bold"], ["italic", "Italic"], ["superscript", "Superscript"], ["subscript", "Subscript"], ] as const; const elementLexicalFormats = [ ["bold", "b"], ["italic", "i"], ["superscript", "sup"], ["subscript", "sub"], ] as const; const $writeUpdates = ( node: ElementNode, instanceChildren: Instance["children"], instancesList: Instance[], refs: Refs, newLinkKeyToInstanceId: Refs ) => { const children = node.getChildren(); for (const child of children) { if ($isParagraphNode(child)) { $writeUpdates( child, instanceChildren, instancesList, refs, newLinkKeyToInstanceId ); } if ($isLineBreakNode(child)) { instanceChildren.push({ type: "text", value: "\n" }); } if ($isLinkNode(child)) { const key = child.getKey(); const id = refs.get(key) ?? newLinkKeyToInstanceId.get(key) ?? nanoid(); refs.set(key, id); instanceChildren.push({ type: "id", value: id, }); const childChildren: Instance["children"] = []; $writeUpdates( child, childChildren, instancesList, refs, newLinkKeyToInstanceId ); instancesList.push({ type: "instance", id, component: elementComponent, tag: "a", children: childChildren, }); } if ($isTextNode(child)) { // support nesting bold into italic and vice versa // considering lexical represents both as single node // and add ref suffix to distinct styling on one node key const text = child.getTextContent(); let parentUpdates = instanceChildren; if ($isSpanNode(child)) { // prematurely generate span id to select it right after applying const key = `${child.getKey()}:span`; const id = refs.get(key) ?? nanoid(); refs.set(key, id); const childChildren: Instance["children"] = []; instancesList.push({ type: "instance", id, component: elementComponent, tag: "span", children: childChildren, }); parentUpdates.push({ type: "id", value: id }); parentUpdates = childChildren; } // convert all lexical formats for (const [format, tag] of elementLexicalFormats) { if (child.hasFormat(format)) { const key = `${child.getKey()}:${format}`; const id = refs.get(key) ?? nanoid(); refs.set(key, id); const childInstance: Instance = { type: "instance", id, component: elementComponent, tag, children: [], }; instancesList.push(childInstance); parentUpdates.push({ type: "id", value: id }); parentUpdates = childInstance.children; } } parentUpdates.push({ type: "text", value: text }); } } }; export const $convertToUpdates = ( treeRootInstance: Instance, refs: Refs, newLinkKeyToInstanceId: Refs ) => { const treeRootInstanceChildren: Instance["children"] = []; const instancesList: Instance[] = [ { ...treeRootInstance, children: treeRootInstanceChildren, }, ]; const root = $getRoot(); $writeUpdates( root, treeRootInstanceChildren, instancesList, refs, newLinkKeyToInstanceId ); return instancesList; }; const $writeLexical = ( parent: ElementNode | TextNode, children: Instance["children"], instances: Instances, refs: Refs ) => { for (const child of children) { if (child.type === "text") { // convert text if (child.value === "\n" && $isElementNode(parent)) { const lineBreakNode = $createLineBreakNode(); parent.append(lineBreakNode); continue; } if ($isTextNode(parent)) { parent.setTextContent(child.value); } else { const textNode = $createTextNode(child.value); parent.append(textNode); } continue; } const instance = instances.get(child.value); if (instance === undefined) { continue; } // convert instances const isLinkInstance = instance.component === "RichTextLink" || (instance.component === elementComponent && instance.tag === "a"); if (isLinkInstance && $isElementNode(parent)) { const linkNode = $createLinkNode(""); refs.set(linkNode.getKey(), instance.id); parent.append(linkNode); $writeLexical(linkNode, instance.children, instances, refs); } if ( instance.component === "Span" || (instance.component === elementComponent && instance.tag === "span") ) { let textNode; if ($isTextNode(parent)) { textNode = parent; } else { textNode = $createTextNode(""); parent.append(textNode); } $setNodeSpan(textNode); refs.set(`${textNode.getKey()}:span`, instance.id); $writeLexical(textNode, instance.children, instances, refs); } // convert all lexical formats for (const [format, component] of legacyLexicalFormats) { if (instance.component === component) { let textNode; if ($isTextNode(parent)) { textNode = parent; } else { textNode = $createTextNode(""); parent.append(textNode); } textNode.toggleFormat(format); refs.set(`${textNode.getKey()}:${format}`, instance.id); $writeLexical(textNode, instance.children, instances, refs); } } // convert all lexical formats for (const [format, tag] of elementLexicalFormats) { if (instance.component === elementComponent && instance.tag === tag) { let textNode; if ($isTextNode(parent)) { textNode = parent; } else { textNode = $createTextNode(""); parent.append(textNode); } textNode.toggleFormat(format); refs.set(`${textNode.getKey()}:${format}`, instance.id); $writeLexical(textNode, instance.children, instances, refs); } } } }; export const $convertToLexical = ( instances: Instances, rootInstanceId: Instance["id"], refs: Refs ) => { const root = $getRoot(); const p = $createParagraphNode(); root.append(p); const rootInstance = instances.get(rootInstanceId); if (rootInstance) { $writeLexical(p, rootInstance.children, instances, refs); } }; ================================================ FILE: apps/builder/app/canvas/features/text-editor/text-editor.stories.tsx ================================================ import { useEffect, useState } from "react"; import { useStore } from "@nanostores/react"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import type { StoryFn, Meta } from "@storybook/react"; import { action } from "@storybook/addon-actions"; import { Box, Button, Flex, StorySection } from "@webstudio-is/design-system"; import { theme } from "@webstudio-is/design-system"; import type { Instance, Instances, Props } from "@webstudio-is/sdk"; import { $, renderData } from "@webstudio-is/template"; import { $instances, $pages, $registeredComponentMetas, $textEditingInstanceSelector, $textToolbar, } from "~/shared/nano-states"; import { TextEditor } from "./text-editor"; import { emitCommand, subscribeCommands } from "~/canvas/shared/commands"; import { $awareness } from "~/shared/awareness"; export default { component: TextEditor, title: "Canvas/Text editor", } satisfies Meta; const createInstancePair = ( id: Instance["id"], component: string, children: Instance["children"] ): [Instance["id"], Instance] => { return [ id, { type: "instance", id, component, children, }, ]; }; const instances: Instances = new Map([ createInstancePair("1", "Text", [ { type: "text", value: "Paragraph you can edit Blabla " }, { type: "id", value: "2" }, { type: "id", value: "3" }, { type: "id", value: "5" }, ]), createInstancePair("2", "Bold", [ { type: "text", value: "Very Very very bold text " }, ]), createInstancePair("3", "Bold", [{ type: "id", value: "4" }]), createInstancePair("4", "Italic", [ { type: "text", value: "And Bold Small with small italic" }, ]), createInstancePair("5", "Bold", [ { type: "text", value: " la la la subtext" }, ]), ]); const props: Props = new Map(); export const Basic: StoryFn = () => { const state = useStore($textToolbar); useEffect(subscribeCommands, []); return (
div": { padding: `0 ${theme.spacing[5]}`, border: "1px solid #999", color: "black", }, }} > } onChange={action("onChange")} onSelectInstance={(instanceId) => console.info("select instance", instanceId) } />
); }; export const CursorPositioning: StoryFn = () => { const textEditingInstanceSelector = useStore($textEditingInstanceSelector); return ( div": { padding: 40, backgroundColor: textEditingInstanceSelector ? "unset" : "rgba(0,0,0,0.1)", }, border: "1px solid #999", color: "black", " *": { outline: "none", }, }} onClick={(event) => { if (textEditingInstanceSelector !== undefined) { return; } $textEditingInstanceSelector.set({ selector: ["1"], reason: "click", mouseX: event.clientX, mouseY: event.clientY, }); }} > {textEditingInstanceSelector && ( } onChange={action("onChange")} onSelectInstance={(instanceId) => console.info("select instance", instanceId) } /> )} {!textEditingInstanceSelector && (
Paragraph you can edit Blabla Very Very very bold text And Bold Small with small italic la la la subtext
)}

Click on text above, see cursor position and start editing text
{textEditingInstanceSelector && ( )}
); }; export const CursorPositioningUpDown: StoryFn = () => { const [{ instances }, setState] = useState(() => { $pages.set({ folders: [], homePage: { id: "homePageId", rootInstanceId: "bodyId", meta: {}, path: "", title: "", name: "", }, pages: [ { id: "pageId", rootInstanceId: "bodyId", path: "", title: "", name: "", meta: {}, }, ], }); $awareness.set({ pageId: "pageId" }); $registeredComponentMetas.set( new Map([ ["Box", { icon: "icon" }], ["Bold", { icon: "icon" }], ]) ); return renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxAId"> Hello world <$.Bold ws:id="boldA">Hello world Hello world world Hello worldsdsdj skdk ls dk jslkdjklsjdkl sdk jskdj ksjd lksdj dsj <$.Box ws:id="boxBId"> Let it be Let it be <$.Bold ws:id="boldB">Let it be Let Let it be Let it be Let it be Let it be Let it be Let it be ); }); useEffect(() => { $instances.set(instances); }, [instances]); const textEditingInstanceSelector = useStore($textEditingInstanceSelector); return ( div > div": { padding: 5, border: "1px solid #999", }, "& *[aria-readonly]": { backgroundColor: "rgba(0,0,0,0.02)", }, "& strong": { fontSize: "1.5em", }, color: "black", " *": { outline: "none", }, }} >
} onChange={(data) => { setState((prev) => { for (const instance of data) { prev.instances.set(instance.id, instance); } return prev; }); }} onSelectInstance={(instanceId) => console.info("select instance", instanceId) } />
} onChange={(data) => { setState((prev) => { for (const instance of data) { prev.instances.set(instance.id, instance); } return prev; }); }} onSelectInstance={(instanceId) => console.info("select instance", instanceId) } />

Use arrows to move between editors, clicks are not working
); }; ================================================ FILE: apps/builder/app/canvas/features/text-editor/text-editor.tsx ================================================ import * as colorjs from "colorjs.io/fn"; import { useState, useEffect, useLayoutEffect, useCallback, useRef, type JSX, } from "react"; import { KEY_ENTER_COMMAND, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_EDITOR, RootNode, ElementNode, $createLineBreakNode, $getSelection, $isRangeSelection, type EditorState, $isLineBreakNode, COMMAND_PRIORITY_LOW, $setSelection, $getRoot, $isTextNode, $isElementNode, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_LEFT_COMMAND, $createRangeSelection, COMMAND_PRIORITY_CRITICAL, $getNearestNodeFromDOMNode, // eslint-disable-next-line camelcase $normalizeSelection__EXPERIMENTAL, type LexicalEditor, type SerializedEditorState, $createTextNode, KEY_DOWN_COMMAND, COMMAND_PRIORITY_NORMAL, type NodeKey, $getNodeByKey, SELECTION_CHANGE_COMMAND, $selectAll, } from "lexical"; import { LinkNode } from "@lexical/link"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; import { nanoid } from "nanoid"; import { createRegularStyleSheet } from "@webstudio-is/css-engine"; import type { Instance, Instances, Props } from "@webstudio-is/sdk"; import { inflatedAttribute, idAttribute, selectorIdAttribute, } from "@webstudio-is/react-sdk"; import { isDescendantOrSelf, type InstanceSelector } from "~/shared/tree-utils"; import { ToolbarConnectorPlugin } from "./toolbar-connector"; import { type Refs, $convertToLexical, $convertToUpdates } from "./interop"; import { useEffectEvent } from "~/shared/hook-utils/effect-event"; import { deleteInstanceMutable, findAllEditableInstanceSelector, updateWebstudioData, } from "~/shared/instance-utils"; import { $blockChildOutline, $hoveredInstanceOutline, $hoveredInstanceSelector, $instances, $registeredComponentMetas, $selectedInstanceSelector, $textEditingInstanceSelector, $textEditorContextMenu, execTextEditorContextMenuCommand, findBlockChildSelector, findTemplates, } from "~/shared/nano-states"; import { getElementByInstanceSelector, getVisibleElementsByInstanceSelector, } from "~/shared/dom-utils"; import deepEqual from "fast-deep-equal"; import { inflateInstance } from "~/canvas/inflator"; import { $selectedPage, addTemporaryInstance, getInstancePath, selectInstance, } from "~/shared/awareness"; import { shallowEqual } from "shallow-equal"; import { insertListItemAt, insertTemplateAt, } from "~/builder/features/workspace/canvas-tools/outline/block-utils"; import { richTextPlaceholders } from "~/shared/content-model"; const BindInstanceToNodePlugin = ({ refs, rootInstanceSelector, }: { refs: Refs; rootInstanceSelector: InstanceSelector; }) => { const [editor] = useLexicalComposerContext(); useEffect(() => { for (const [nodeKey, instanceId] of refs) { // extract key from stored key:style format const [key] = nodeKey.split(":"); const element = editor.getElementByKey(key); if (element) { element.setAttribute(idAttribute, instanceId); // We set id + root selector here, for simplicity // This solves hover behavior during mouseMove for editable child outline // @todo: A normal selector must be used, but it would require significantly more code to detect the tree structure. element.setAttribute( selectorIdAttribute, [instanceId, ...rootInstanceSelector].join(",") ); } } }, [editor, refs, rootInstanceSelector]); return null; }; /** * In case of text color is near transparent, make caret visible with color animation between #666 and #999 */ const CaretColorPlugin = () => { const [editor] = useLexicalComposerContext(); const caretClassName = useState(() => `a${nanoid()}`)[0]; useEffect(() => { const rootElement = editor.getRootElement(); if (rootElement === null) { return; } const elementColor = window.getComputedStyle(rootElement).color; let isLightBackground = false; try { const color = colorjs.parse(elementColor); const alpha = color.alpha ?? 1; isLightBackground = alpha < 0.1; } catch { // If we can't parse the color, assume it's not light } if (isLightBackground) { // Apply caret color with animated color const sheet = createRegularStyleSheet({ name: "text-editor-caret" }); // Animation on cursor needed to make it visible on any background sheet.addPlaintextRule(` @keyframes ${caretClassName}-keyframes { from {caret-color: #666;} to {caret-color: #999;} } .${caretClassName} { animation-name: ${caretClassName}-keyframes; animation-duration: 0.5s; animation-iteration-count: infinite; animation-direction: alternate; } `); rootElement.classList.add(caretClassName); sheet.render(); return () => { rootElement.classList.remove(caretClassName); sheet.unmount(); }; } }, [caretClassName, editor]); return null; }; const isChrome = () => navigator.userAgentData?.brands.some( (brand) => brand.brand === "Google Chrome" ); const OnChangeOnBlurPlugin = ({ onChange, }: { onChange: (editorState: EditorState, reason: "blur" | "unmount") => void; }) => { const [editor] = useLexicalComposerContext(); const handleChange = useEffectEvent(onChange); useEffect( () => () => { // Ensures editable content is saved if no blur event occurs before unmount. // This can happen in Firefox and Safari. // To reproduce: create a Content Block, edit a paragraph, then type `/` and select Heading or Paragraph from the menu. // Without this, changes may be lost on unmount in FF and Safari. if (isChrome()) { // Fixes an issue in DEV MODE where, if text is center-aligned inside Flex/Grid, // the code below causes Chrome to scroll the editable text block to the center of the view. return; } // The issue is related to React’s development mode. // When we set the initial selection in the Editor, we disable Lexical’s internal // scrolling using the update operation tag tag: "skip-scroll-into-view". // The problem is that a read operation forces all pending update operations to commit, // and for some reason, this forced commit does not respect tags. // In React’s development mode, useEffect runs twice, which causes scrollIntoView // to be called during the first read. // To prevent this, we disconnect the editor from the DOM // by setting editor._rootElement = null;. // This makes Lexical assume it’s in headless mode, // preventing it from executing DOM operations. editor._rootElement = null; // Safari and FF support as no blur event is triggered in some cases editor.read(() => { handleChange(editor.getEditorState(), "unmount"); }); }, [editor] ); useEffect(() => { const handleBlur = () => { // force read to get the latest state editor.read(() => { handleChange(editor.getEditorState(), "blur"); }); }; // https://github.com/facebook/lexical/blob/867d449b2a6497ff9b1fbdbd70724c74a1044d8b/packages/lexical-react/src/LexicalNodeEventPlugin.ts#L59C12-L67C8 return editor.registerRootListener((rootElement, prevRootElement) => { rootElement?.addEventListener("blur", handleBlur); prevRootElement?.removeEventListener("blur", handleBlur); }); }, [editor]); return null; }; const getNodeKeyFromDOMNode = ( dom: Node, editor: LexicalEditor ): NodeKey | undefined => { const prop = `__lexicalKey_${editor._key}`; return (dom as Node & Record)[prop]; }; const LinkSelectionPlugin = ({ rootInstanceSelector, registerNewLink, }: { rootInstanceSelector: InstanceSelector; registerNewLink: (key: NodeKey, instanceId: string) => void; }) => { const [editor] = useLexicalComposerContext(); const [preservedSelection] = useState(rootInstanceSelector); useEffect(() => { if (!editor.isEditable()) { return; } const removeUpdateListener = editor.registerUpdateListener( ({ editorState }) => { editorState.read(() => { const selectedInstanceSelector = $selectedInstanceSelector.get(); if (selectedInstanceSelector === undefined) { return; } if ( !isDescendantOrSelf(selectedInstanceSelector, preservedSelection) ) { return; } const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } const key = selection.anchor.getNode().getKey(); const elt = editor.getElementByKey(key); let link = elt?.closest(`a[${selectorIdAttribute}]`); const newLink = elt?.closest(`a`); while (newLink != null && link == null) { // new link detected // https://github.com/facebook/lexical/blob/b7fa4cf673869dac0c2e0c1fe667e71e72ff6adb/packages/lexical/src/LexicalUtils.ts#L465 const key = getNodeKeyFromDOMNode(newLink, editor); if (key === undefined) { console.error("Key not found for node", newLink); break; } // Register new link const instanceId = nanoid(); newLink.setAttribute(idAttribute, instanceId); // We set id + root selector here, for simplicity // This solves hover behavior during mouseMove for editable child outline // @todo: A normal selector must be used, but it would require significantly more code to detect the tree structure. newLink.setAttribute( selectorIdAttribute, [instanceId, ...rootInstanceSelector].join(",") ); registerNewLink(key, instanceId); link = newLink; break; } if (link == null) { if ( shallowEqual(preservedSelection, $selectedInstanceSelector.get()) ) { return false; } selectInstance(preservedSelection); return false; } const selectorAttribute = link .getAttribute(selectorIdAttribute) ?.split(","); if (selectorAttribute === undefined) { return false; } if ( shallowEqual(selectorAttribute, $selectedInstanceSelector.get()) ) { return false; } selectInstance(selectorAttribute); }); } ); return () => { removeUpdateListener(); }; }, [editor, preservedSelection, registerNewLink, rootInstanceSelector]); return null; }; const RemoveParagaphsPlugin = () => { const [editor] = useLexicalComposerContext(); // register own commands before RichTextPlugin // to stop propagation useLayoutEffect(() => { const removeCommand = editor.registerCommand( KEY_ENTER_COMMAND, (event) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } event?.preventDefault(); // returns true which stops propagation return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); }, COMMAND_PRIORITY_EDITOR ); // merge pasted paragraphs into single one // and separate lines with line breaks const removeNodeTransform = editor.registerNodeTransform( RootNode, (node) => { // merge paragraphs into first with line breaks between if (node.getChildrenSize() > 1) { const children = node.getChildren(); let first; for (let index = 0; index < children.length; index += 1) { const paragraph = children[index]; // With default configuration root contains only paragraphs. // Lexical converts headings to paragraphs on paste for example. // So he we just check root children which are all paragraphs. if (paragraph instanceof ElementNode) { if (index === 0) { first = paragraph; } else if (first) { first.append($createLineBreakNode()); for (const child of paragraph.getChildren()) { first.append(child); } paragraph.remove(); } } } } } ); return () => { removeCommand(); removeNodeTransform(); }; }, [editor]); return null; }; const isSelectionLastNode = () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } const rootNode = $getRoot(); const lastNode = rootNode.getLastDescendant(); const anchor = selection.anchor; if ($isLineBreakNode(lastNode)) { const anchorNode = anchor.getNode(); return ( $isElementNode(anchorNode) && anchorNode.getLastDescendant() === lastNode && anchor.offset === anchorNode.getChildrenSize() ); } else if ($isTextNode(lastNode)) { return ( anchor.offset === lastNode.getTextContentSize() && anchor.getNode() === lastNode ); } else if ($isElementNode(lastNode)) { return ( anchor.offset === lastNode.getChildrenSize() && anchor.getNode() === lastNode ); } return false; }; const isSelectionFirstNode = () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } const rootNode = $getRoot(); const firstNode = rootNode.getFirstDescendant(); const anchor = selection.anchor; if ($isLineBreakNode(firstNode)) { const anchorNode = anchor.getNode(); return ( $isElementNode(anchorNode) && anchorNode.getFirstDescendant() === firstNode && anchor.offset === 0 ); } else if ($isTextNode(firstNode)) { return anchor.offset === 0 && anchor.getNode() === firstNode; } else if ($isElementNode(firstNode)) { return anchor.offset === 0 && anchor.getNode() === firstNode; } return false; }; const getDomSelectionRect = () => { const domSelection = window.getSelection(); if (!domSelection || !domSelection.focusNode) { return; } // Get current line position const range = domSelection.getRangeAt(0); // The cursor position at the beginning of a line is technically associated with both: // The end of the previous line // The beginning of the current line // Select the rectangle for the current line. It typically appears as the last rect in the list. const rects = range.getClientRects(); const currentRect = rects[rects.length - 1] ?? undefined; return currentRect; }; const getVerticalIntersectionRatio = (rectA: DOMRect, rectB: DOMRect) => { const topIntersection = Math.max(rectA.top, rectB.top); const bottomIntersection = Math.min(rectA.bottom, rectB.bottom); const intersectionHeight = Math.max(0, bottomIntersection - topIntersection); const minHeight = Math.min(rectA.height, rectB.height); return minHeight === 0 ? 0 : intersectionHeight / minHeight; }; const caretFromPoint = ( x: number, y: number ): null | { offset: number; node: Node; } => { if (typeof document.caretRangeFromPoint !== "undefined") { const range = document.caretRangeFromPoint(x, y); if (range === null) { return null; } return { node: range.startContainer, offset: range.startOffset, }; } else if (typeof document.caretPositionFromPoint !== "undefined") { const range = document.caretPositionFromPoint(x, y); if (range === null) { return null; } return { node: range.offsetNode, offset: range.offset, }; } else { // Gracefully handle IE return null; } }; /** * Select all TEXT nodes inside editor root, then find the top and bottom rects */ const getTopBottomRects = ( editor: LexicalEditor ): [topRects: DOMRect[], bottomRects: DOMRect[]] => { const rootElement = editor.getElementByKey($getRoot().getKey()); if (rootElement == null) { return [[], []]; } const walker = document.createTreeWalker( rootElement, NodeFilter.SHOW_TEXT, null ); const allRects: DOMRect[] = []; while (walker.nextNode()) { const range = document.createRange(); range.selectNodeContents(walker.currentNode); const rects = range.getClientRects(); allRects.push(...Array.from(rects)); } if (allRects.length === 0) { return [[], []]; } const topRect = Array.from(allRects).sort((a, b) => a.top - b.top)[0]; const bottomRect = Array.from(allRects).sort( (a, b) => b.bottom - a.bottom )[0]; const topRects = allRects.filter( (rect) => getVerticalIntersectionRatio(rect, topRect) > 0.5 ); const bottomRects = allRects.filter( (rect) => getVerticalIntersectionRatio(rect, bottomRect) > 0.5 ); return [topRects, bottomRects]; }; const InitCursorPlugin = () => { const [editor] = useLexicalComposerContext(); useEffect(() => { if (!editor.isEditable()) { return; } editor.update( () => { const textEditingInstanceSelector = $textEditingInstanceSelector.get(); if (textEditingInstanceSelector === undefined) { return; } const { reason } = textEditingInstanceSelector; if (reason === undefined) { return; } if (reason === "click") { const { mouseX, mouseY } = textEditingInstanceSelector; const eventRange = caretFromPoint(mouseX, mouseY); if (eventRange !== null) { const { offset: domOffset, node: domNode } = eventRange; const node = $getNearestNodeFromDOMNode(domNode); if (node !== null) { const selection = $createRangeSelection(); if ($isTextNode(node)) { selection.anchor.set(node.getKey(), domOffset, "text"); selection.focus.set(node.getKey(), domOffset, "text"); const normalizedSelection = $normalizeSelection__EXPERIMENTAL(selection); $setSelection(normalizedSelection); return; } } if (domNode instanceof Element) { const rect = domNode.getBoundingClientRect(); if (mouseX > rect.right) { const selection = $getRoot().selectEnd(); $setSelection(selection); return; } } } } while (reason === "down" || reason === "up") { const { cursorX } = textEditingInstanceSelector; const [topRects, bottomRects] = getTopBottomRects(editor); // Smoodge the cursor a little to the left and right to find the nearest text node const smoodgeOffsets = [1, 2, 4]; const maxOffset = Math.max(...smoodgeOffsets); const rects = reason === "down" ? topRects : bottomRects; rects.sort((a, b) => a.left - b.left); const rectWithText = rects.find( (rect, index) => rect.left - (index === 0 ? maxOffset : 0) <= cursorX && cursorX <= rect.right + (index === rects.length - 1 ? maxOffset : 0) ); if (rectWithText === undefined) { break; } const newCursorY = rectWithText.top + rectWithText.height / 2; const eventRanges = [caretFromPoint(cursorX, newCursorY)]; for (const offset of smoodgeOffsets) { eventRanges.push(caretFromPoint(cursorX - offset, newCursorY)); eventRanges.push(caretFromPoint(cursorX + offset, newCursorY)); } for (const eventRange of eventRanges) { if (eventRange === null) { continue; } const { offset: domOffset, node: domNode } = eventRange; const node = $getNearestNodeFromDOMNode(domNode); if (node !== null && $isTextNode(node)) { const selection = $createRangeSelection(); selection.anchor.set(node.getKey(), domOffset, "text"); selection.focus.set(node.getKey(), domOffset, "text"); const normalizedSelection = $normalizeSelection__EXPERIMENTAL(selection); $setSelection(normalizedSelection); return; } } break; } if ( reason === "down" || reason === "right" || reason === "enter" || reason === "click" ) { const firstNode = $getRoot().getFirstDescendant(); if (firstNode === null) { return; } if ($isTextNode(firstNode)) { const selection = $createRangeSelection(); selection.anchor.set(firstNode.getKey(), 0, "text"); selection.focus.set(firstNode.getKey(), 0, "text"); $setSelection(selection); } if ($isElementNode(firstNode)) { // e.g. Box is empty const selection = $createRangeSelection(); selection.anchor.set(firstNode.getKey(), 0, "element"); selection.focus.set(firstNode.getKey(), 0, "element"); $setSelection(selection); } if ($isLineBreakNode(firstNode)) { // e.g. Box contains 2+ empty lines const selection = $createRangeSelection(); $setSelection(selection); } return; } if (reason === "up" || reason === "left") { const selection = $createRangeSelection(); const lastNode = $getRoot().getLastDescendant(); if (lastNode === null) { return; } if ($isTextNode(lastNode)) { const contentSize = lastNode.getTextContentSize(); selection.anchor.set(lastNode.getKey(), contentSize, "text"); selection.focus.set(lastNode.getKey(), contentSize, "text"); $setSelection(selection); } if ($isElementNode(lastNode)) { // e.g. Box is empty const selection = $createRangeSelection(); selection.anchor.set(lastNode.getKey(), 0, "element"); selection.focus.set(lastNode.getKey(), 0, "element"); $setSelection(selection); } if ($isLineBreakNode(lastNode)) { // e.g. Box contains 2+ empty lines const parent = lastNode.getParent(); if ($isElementNode(parent)) { const selection = $createRangeSelection(); selection.anchor.set( parent.getKey(), parent.getChildrenSize(), "element" ); selection.focus.set( parent.getKey(), parent.getChildrenSize(), "element" ); $setSelection(selection); } } return; } if (reason === "new") { $selectAll(); return; } reason satisfies never; }, { // We are controlling scroll ourself in instance-selected.ts see updateScroll. // Without skipping we are getting side effects of composition in scrollBy, scrollIntoView calls tag: "skip-scroll-into-view", } ); }, [editor]); return null; }; type HandleNextParams = | { reason: "up" | "down"; cursorX: number; } | { reason: "right" | "left"; }; type SwitchBlockPluginProps = { onNext: (editorState: EditorState, params: HandleNextParams) => void; }; const isSingleCursorSelection = () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } const isCaret = selection.anchor.offset === selection.focus.offset && selection.anchor.key === selection.focus.key; if (!isCaret) { return false; } return true; }; const SwitchBlockPlugin = ({ onNext }: SwitchBlockPluginProps) => { const [editor] = useLexicalComposerContext(); useEffect(() => { // The right arrow key should move the cursor to the next block only if it is at the end of the current block. return editor.registerCommand( KEY_ARROW_RIGHT_COMMAND, (event) => { if (!isSingleCursorSelection()) { return false; } const isLast = isSelectionLastNode(); if (isLast) { const state = editor.getEditorState(); onNext(state, { reason: "right" }); event?.preventDefault(); return true; } return false; }, COMMAND_PRIORITY_LOW ); }, [editor, onNext]); useEffect(() => { // The left arrow key should move the cursor to the previous block only if it is at the start of the current block. return editor.registerCommand( KEY_ARROW_LEFT_COMMAND, (event) => { if (!isSingleCursorSelection()) { return false; } const isFirst = isSelectionFirstNode(); if (isFirst) { const state = editor.getEditorState(); onNext(state, { reason: "left" }); event?.preventDefault(); return true; } return false; }, COMMAND_PRIORITY_LOW ); }, [editor, onNext]); useEffect(() => { // The down arrow key should move the cursor to the next block if: // - it is at the end of the current block // - the cursor is at the last line of the current block return editor.registerCommand( KEY_ARROW_DOWN_COMMAND, (event) => { if (!isSingleCursorSelection()) { return false; } const isLast = isSelectionLastNode(); const rect = getDomSelectionRect(); if (isLast) { const state = editor.getEditorState(); onNext(state, { reason: "down", cursorX: rect?.x ?? 0 }); event?.preventDefault(); return true; } // Check if the cursor is inside a rectangle on the last line if (rect === undefined) { return false; } const rootNode = $getRoot(); const lastNode = rootNode.getLastDescendant(); if ($isLineBreakNode(lastNode)) { return false; } const [, lineRects] = getTopBottomRects(editor); const cursorY = rect.y + rect.height / 2; if ( lineRects.some( (lineRect) => lineRect.left <= rect.x && rect.x <= lineRect.right && lineRect.top <= cursorY && cursorY <= lineRect.bottom ) ) { const state = editor.getEditorState(); onNext(state, { reason: "down", cursorX: rect?.x ?? 0 }); event?.preventDefault(); return true; } return false; }, COMMAND_PRIORITY_CRITICAL ); }, [editor, onNext]); useEffect(() => { // The up arrow key should move the cursor to the previous block if: // - it is at the start of the current block // - the cursor is at the first line of the current block return editor.registerCommand( KEY_ARROW_UP_COMMAND, (event) => { if (!isSingleCursorSelection()) { return false; } const isFirst = isSelectionFirstNode(); const rect = getDomSelectionRect(); if (isFirst) { const state = editor.getEditorState(); onNext(state, { reason: "up", cursorX: rect?.x ?? 0 }); event?.preventDefault(); return true; } if (rect === undefined) { return false; } const rootNode = $getRoot(); const lastNode = rootNode.getFirstDescendant(); if ($isLineBreakNode(lastNode)) { return false; } const [lineRects] = getTopBottomRects(editor); const cursorY = rect.y + rect.height / 2; if ( lineRects.some( (lineRect) => lineRect.left <= rect.x && rect.x <= lineRect.right && lineRect.top <= cursorY && cursorY <= lineRect.bottom ) ) { const state = editor.getEditorState(); onNext(state, { reason: "up", cursorX: rect?.x ?? 0 }); event?.preventDefault(); return true; } // Lexical has a bug where the cursor sometimes stops moving up. // Slight adjustments fix this issue. const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.modify("move", false, "character"); selection.modify("move", true, "character"); return false; }, COMMAND_PRIORITY_CRITICAL ); }, [editor, onNext]); return null; }; type ContextMenuParams = { cursorRect: DOMRect; }; type RichTextContentPluginProps = { rootInstanceSelector: InstanceSelector; onOpen: ( editorState: EditorState, params: undefined | ContextMenuParams ) => void; onNext: (editorState: EditorState, params: HandleNextParams) => void; }; const RichTextContentPlugin = (props: RichTextContentPluginProps) => { const [templates] = useState(() => findTemplates(props.rootInstanceSelector, $instances.get()) ); if (templates === undefined) { return; } if (templates.length === 0) { return; } return ; }; const getTag = (instanceId: Instance["id"]) => { const instances = $instances.get(); const metas = $registeredComponentMetas.get(); const instance = instances.get(instanceId); if (instance === undefined) { return; } const meta = metas.get(instance.component); const tags = Object.keys(meta?.presetStyle ?? {}); return instance.tag ?? tags[0]; }; const RichTextContentPluginInternal = ({ rootInstanceSelector, onOpen, templates, onNext, }: RichTextContentPluginProps & { templates: [instance: Instance, instanceSelector: InstanceSelector][]; }) => { const [editor] = useLexicalComposerContext(); const [preservedSelection] = useState(rootInstanceSelector); const handleOpen = useEffectEvent(onOpen); useEffect(() => { if (!editor.isEditable()) { return; } let menuState: "closed" | "opening" | "opened" = "closed"; let slashNodeKey: NodeKey | undefined = undefined; const closeMenu = () => { if (menuState === "closed") { return; } menuState = "closed"; handleOpen(editor.getEditorState(), undefined); if (slashNodeKey === undefined) { return; } const node = $getNodeByKey(slashNodeKey); if ($isTextNode(node)) { node.setStyle(""); } const selectedInstanceSelector = $selectedInstanceSelector.get(); const isSelectionInSameComponent = selectedInstanceSelector ? isDescendantOrSelf(selectedInstanceSelector, preservedSelection) : false; if (!isSelectionInSameComponent) { node?.remove(); // Delete current if ($getRoot().getTextContentSize() === 0) { const blockChildSelector = findBlockChildSelector(rootInstanceSelector); if (blockChildSelector) { updateWebstudioData((data) => { deleteInstanceMutable( data, getInstancePath(rootInstanceSelector, data.instances) ); }); } } } // if selection changed, remove the slash node const selection = $getSelection(); if (!$isRangeSelection(selection)) { return; } selection.setStyle(""); }; const unsubscibeSelectionChange = editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { if (menuState !== "opened") { return false; } const selection = $getSelection(); if (!$isRangeSelection(selection)) { closeMenu(); return false; } if (selection.anchor.key !== slashNodeKey) { closeMenu(); return false; } return false; }, COMMAND_PRIORITY_LOW ); const unsubscibeKeyDown = editor.registerCommand( KEY_DOWN_COMMAND, (event) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } if (event.key === "Backspace" || event.key === "Delete") { if ($getRoot().getTextContentSize() === 0) { const tag = getTag(rootInstanceSelector[0]); if (tag === "li") { onNext(editor.getEditorState(), { reason: "left" }); const parentInstanceSelector = rootInstanceSelector.slice(1); const parentInstance = $instances .get() .get(parentInstanceSelector[0]); const isLastChild = parentInstance?.children.length === 1; updateWebstudioData((data) => { deleteInstanceMutable( data, getInstancePath( isLastChild ? parentInstanceSelector : rootInstanceSelector, data.instances ) ); }); event.preventDefault(); return true; } const blockChildSelector = findBlockChildSelector(rootInstanceSelector); if (blockChildSelector) { onNext(editor.getEditorState(), { reason: "left" }); updateWebstudioData((data) => { deleteInstanceMutable( data, getInstancePath(blockChildSelector, data.instances) ); }); event.preventDefault(); return true; } } } if (menuState === "closed") { if (event.key === "Enter" && !event.shiftKey) { // Custom logic if we are editing list item const tag = getTag(rootInstanceSelector[0]); if (tag === "li" && $getRoot().getTextContentSize() > 0) { // Instead of creating block component we need to add a new list item insertListItemAt(rootInstanceSelector); event.preventDefault(); return true; } // Check if it pressed on the last line, last symbol const allowedTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6"]; for (const tag of allowedTags) { const templateSelector = templates.find( ([instance]) => getTag(instance.id) === tag )?.[1]; if (templateSelector === undefined) { continue; } /* @todo Split logic idea // clone root node then // getPreviousSibling const removeNextSiblings = (node: LexicalNode) => { let current: LexicalNode | null = node; while (current) { const next = current.getNextSibling(); if (next) { next.remove(); continue; } // Move up to parent and continue removing siblings current = current.getParent(); if ($isRootNode(current)) { break; } } }; const anchorNode = selection.anchor.getNode(); const anchorOffset = selection.anchor.offset; if (!$isTextNode(anchorNode)) { continue; } anchorNode.splitText(anchorOffset); removeNextSiblings(anchorNode); */ insertTemplateAt(templateSelector, rootInstanceSelector, false); if (tag === "li" && $getRoot().getTextContentSize() === 0) { const parentInstanceSelector = rootInstanceSelector.slice(1); const parentInstance = $instances .get() .get(parentInstanceSelector[0]); const isLastChild = parentInstance?.children.length === 1; // Pressing Enter within an empty list item deletes the empty item updateWebstudioData((data) => { deleteInstanceMutable( data, getInstancePath( isLastChild ? parentInstanceSelector : rootInstanceSelector, data.instances ) ); }); } event.preventDefault(); return true; } } } if (menuState === "opened") { if (event.key === "Escape") { closeMenu(); event.preventDefault(); return true; } if (event.key === " ") { closeMenu(); } if (event.key === "/") { closeMenu(); } if (event.key === "Enter") { execTextEditorContextMenuCommand({ type: "enter", }); event.preventDefault(); return true; } if (event.key === "ArrowUp") { execTextEditorContextMenuCommand({ type: "selectPrevious", }); event.preventDefault(); return true; } if (event.key === "ArrowDown") { execTextEditorContextMenuCommand({ type: "selectNext", }); event.preventDefault(); return true; } } if (menuState === "closed") { if (event.key !== "/") { return false; } const slashNode = $createTextNode("/"); slashNodeKey = slashNode.getKey(); menuState = "opening"; slashNode.setStyle("background-color: rgba(127, 127, 127, 0.2);"); selection.setStyle("background-color: rgba(127, 127, 127, 0.2);"); selection.insertNodes([slashNode]); event.preventDefault(); return true; } return false; }, COMMAND_PRIORITY_EDITOR ); const closeMenuWithUpdate = () => { if (menuState === "closed") { return; } editor.update(() => { closeMenu(); }); }; const unsubscribeUpdateListener = editor.registerUpdateListener( ({ editorState }) => { if (menuState === "opened") { editorState.read(() => { if (slashNodeKey === undefined) { closeMenu(); return; } const node = $getNodeByKey(slashNodeKey); if (node === null) { closeMenuWithUpdate(); return; } const content = node.getTextContent(); const filter = content.slice(1); execTextEditorContextMenuCommand({ type: "filter", value: filter, }); }); } if (menuState === "opening") { editorState.read(() => { if (slashNodeKey === undefined) { closeMenu(); return; } const slashNode = editor.getElementByKey(slashNodeKey); if (slashNode === null) { closeMenu(); return; } const rect = slashNode.getBoundingClientRect(); menuState = "opened"; handleOpen(editor.getEditorState(), { cursorRect: rect, }); }); } } ); const unsubscribeBlurListener = editor.registerRootListener( (rootElement, prevRootElement) => { rootElement?.addEventListener("blur", closeMenuWithUpdate); prevRootElement?.removeEventListener("blur", closeMenuWithUpdate); } ); return () => { unsubscibeKeyDown(); unsubscribeUpdateListener(); unsubscibeSelectionChange(); unsubscribeBlurListener(); // Safari and FF support as no blur event is triggered in some cases closeMenuWithUpdate(); }; }, [editor, onNext, preservedSelection, rootInstanceSelector, templates]); return null; }; const onError = (error: Error) => { throw error; }; type TextEditorProps = { rootInstanceSelector: InstanceSelector; instances: Instances; props: Props; contentEditable: JSX.Element; editable?: boolean; onChange: (instancesList: Instance[]) => void; onSelectInstance: (instanceId: Instance["id"]) => void; }; const mod = (n: number, m: number) => { return ((n % m) + m) % m; }; const InitialJSONStatePlugin = ({ onInitialState, }: { onInitialState: (json: SerializedEditorState) => void; }) => { const [editor] = useLexicalComposerContext(); const handleInitialState = useEffectEvent(onInitialState); useEffect(() => { handleInitialState(editor.getEditorState().toJSON()); }, [editor]); return null; }; /** * Removes link nodes and converts them to text nodes inside elements. * Solves the issue with pasting from external sources that contain links. */ const LinkSanitizePlugin = (): null => { const [editor] = useLexicalComposerContext(); useEffect(() => { const rootElement = editor.getRootElement(); if (rootElement === null) { return; } if (!(rootElement instanceof HTMLAnchorElement)) { return; } return editor.registerNodeTransform(LinkNode, (linkNode) => { linkNode.insertBefore($createTextNode(linkNode.getTextContent())); linkNode.remove(); }); }, [editor]); return null; }; const AnyKeyDownPlugin = ({ onKeyDown, }: { onKeyDown: (event: KeyboardEvent) => void; }) => { const [editor] = useLexicalComposerContext(); useEffect(() => { return editor.registerCommand( KEY_DOWN_COMMAND, (event) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } onKeyDown(event); return false; }, COMMAND_PRIORITY_NORMAL ); }, [editor, onKeyDown]); return null; }; export const TextEditor = ({ rootInstanceSelector: rootInstanceSelectorUnstable, instances, props, contentEditable, editable, onChange, onSelectInstance, }: TextEditorProps) => { const [rootInstanceSelector] = useState(() => rootInstanceSelectorUnstable); // class names must be started with letter so we add a prefix const [paragraphClassName] = useState(() => `a${nanoid()}`); const [italicClassName] = useState(() => `a${nanoid()}`); const lastSavedStateJsonRef = useRef(null); const [newLinkKeyToInstanceId] = useState(() => new Map()); const handleChange = useEffectEvent( (editorState: EditorState, reason: "blur" | "unmount" | "next") => { editorState.read(() => { const treeRootInstance = instances.get(rootInstanceSelector[0]); if (treeRootInstance) { const jsonState = editorState.toJSON(); if (deepEqual(jsonState, lastSavedStateJsonRef.current)) { inflateInstance(rootInstanceSelector[0], false); return; } onChange( $convertToUpdates(treeRootInstance, refs, newLinkKeyToInstanceId) ); newLinkKeyToInstanceId.clear(); lastSavedStateJsonRef.current = jsonState; } inflateInstance(rootInstanceSelector[0], false); }); const textEditingSelector = $textEditingInstanceSelector.get()?.selector; if (textEditingSelector === undefined) { return; } if (reason === "blur") { if (shallowEqual(textEditingSelector, rootInstanceSelector)) { $textEditingInstanceSelector.set(undefined); } } } ); useLayoutEffect(() => { const sheet = createRegularStyleSheet({ name: "text-editor" }); // reset paragraph styles and make it work inside sheet.addPlaintextRule(` .${paragraphClassName} { display: inline-block; margin: 0; } `); // fixes the bug on canvas that cursor is not shown on empty elements sheet.addPlaintextRule(` .${paragraphClassName}:has(br):not(:has(:not(br))) { min-width: 1px; } `); /// set italic style for bold italic combination on the same element sheet.addPlaintextRule(` .${italicClassName} { font-style: italic; } `); sheet.render(); return () => { sheet.unmount(); }; }, [paragraphClassName, italicClassName]); // store references separately because lexical nodes // cannot store custom data // Map const [refs] = useState(() => new Map()); const initialConfig = { namespace: "WsTextEditor", theme: { paragraph: paragraphClassName, text: { italic: italicClassName, }, }, editable, editorState: () => { const [rootInstanceId] = rootInstanceSelector; // text editor is unmounted when change properties in side panel // so assume new nodes don't need to preserve instance id // and store only initial references $convertToLexical(instances, rootInstanceId, refs); }, nodes: [LinkNode], onError, }; const handleNext = useEffectEvent( (state: EditorState, args: HandleNextParams) => { const rootInstanceId = $selectedPage.get()?.rootInstanceId; const metas = $registeredComponentMetas.get(); if (rootInstanceId === undefined) { return; } const editableInstanceSelectors: InstanceSelector[] = []; findAllEditableInstanceSelector({ instanceSelector: [rootInstanceId], instances, props, metas, results: editableInstanceSelectors, }); const currentIndex = editableInstanceSelectors.findIndex( (instanceSelector) => { return ( instanceSelector[0] === rootInstanceSelector[0] && instanceSelector.join(",") === rootInstanceSelector.join(",") ); } ); if (currentIndex === -1) { return; } for (let i = 1; i < editableInstanceSelectors.length; i++) { const nextIndex = args.reason === "down" || args.reason === "right" ? mod(currentIndex + i, editableInstanceSelectors.length) : mod(currentIndex - i, editableInstanceSelectors.length); const nextSelector = editableInstanceSelectors[nextIndex]; const nextInstance = instances.get(nextSelector[0]); if (nextInstance === undefined) { continue; } const hasExpressionChildren = nextInstance.children.some( (child) => child.type === "expression" ); // opinionated: Skip if binded (double click is working) if (hasExpressionChildren) { continue; } // Skip invisible elements if (getVisibleElementsByInstanceSelector(nextSelector).length === 0) { continue; } const instance = instances.get(nextSelector[0]); if (instance === undefined) { continue; } const meta = metas.get(instance.component); const tags = Object.keys(meta?.presetStyle ?? {}); const tag = instance.tag ?? tags[0]; // opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason). if ( // Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing richTextPlaceholders.has(tag) === false && instance?.children.length === 0 ) { const elt = getElementByInstanceSelector(nextSelector); if (elt === undefined) { continue; } if (!elt.hasAttribute(inflatedAttribute)) { continue; } } handleChange(state, "next"); $textEditingInstanceSelector.set({ selector: nextSelector, ...args, }); selectInstance(nextSelector); break; } } ); const handleAnyKeydown = useCallback((event: KeyboardEvent) => { // Skip alt as Block outline depends on Alt key press if (event.key === "Alt") { return; } $blockChildOutline.set(undefined); $hoveredInstanceOutline.set(undefined); $hoveredInstanceSelector.set(undefined); }, []); const registerNewLink = useCallback( (key: NodeKey, instanceId: string) => { newLinkKeyToInstanceId.set(key, instanceId); addTemporaryInstance({ id: instanceId, component: "RichTextLink", type: "instance", children: [], }); }, [newLinkKeyToInstanceId] ); const handleContextMenuOpen = useCallback( (_editorState: EditorState, params: undefined | ContextMenuParams) => { $textEditorContextMenu.set(params); }, [] ); return ( { const instanceId = refs.get(`${nodeKey}:span`); if (instanceId !== undefined) { onSelectInstance(instanceId); } }} /> { lastSavedStateJsonRef.current = json; }} /> ); }; ================================================ FILE: apps/builder/app/canvas/features/text-editor/toolbar-connector.tsx ================================================ import { useCallback, useEffect, useRef, useState } from "react"; import { type RangeSelection, type TextNode, type LexicalEditor, $getSelection, $isRangeSelection, $isTextNode, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_LOW, type TextFormatType, createCommand, COMMAND_PRIORITY_EDITOR, } from "lexical"; import { $getNearestNodeOfType } from "@lexical/utils"; import { $patchStyleText } from "@lexical/selection"; import { LinkNode } from "@lexical/link"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $textToolbar } from "~/shared/nano-states"; import { subscribeScrollState } from "~/canvas/shared/scroll-state"; let activeEditor: undefined | LexicalEditor; export const getActiveEditor = () => { return activeEditor; }; export const TOGGLE_SPAN_COMMAND = createCommand(); export const CLEAR_FORMAT_COMMAND = createCommand(); const spanTriggerName = "--style-node-trigger"; export const $isSpanNode = (node: TextNode) => { return node.getStyle().includes(spanTriggerName); }; export const $setNodeSpan = (node: TextNode) => { return node.setStyle(`${spanTriggerName}: 1;`); }; const $getSpanNodes = (selection: RangeSelection) => { const nodes = selection.getNodes(); const spans: TextNode[] = []; // check each TextNode within selection for existing span nodes for (const node of nodes) { if ($isTextNode(node) && $isSpanNode(node)) { spans.push(node); } } return spans; }; const $toggleSpan = () => { const selection = $getSelection(); if ($isRangeSelection(selection)) { const spans = $getSpanNodes(selection); if (spans.length === 0) { // lexical creates separate text node when style property do not match $patchStyleText(selection, { [spanTriggerName]: "1", }); } else { // clear span nodes style for (const node of spans) { node.setStyle(""); } } } }; const $clearText = () => { const selection = $getSelection(); if ($isRangeSelection(selection)) { // split nodes by selection and mark with style $patchStyleText(selection, { "--clear-selection-trigger": "1", }); // recompute selection to get new splitted nodes const newSelection = $getSelection(); if ($isRangeSelection(newSelection)) { // update both nodes and selection newSelection.format = 0; for (const node of selection.getNodes()) { if ($isTextNode(node)) { node.setFormat(0); node.setStyle(""); } } } } }; const $isSelectedLink = (selection: RangeSelection) => { const [selectedNode] = selection.getNodes(); return $getNearestNodeOfType(selectedNode, LinkNode) != null; }; export const hasSelectionFormat = (formatType: TextFormatType | "link") => { return activeEditor?.getEditorState().read(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { if (formatType === "link") { return $isSelectedLink(selection); } return selection.hasFormat(formatType); } }); }; const getSelectionClienRect = () => { const nativeSelection = window.getSelection(); if (nativeSelection === null) { return; } if (nativeSelection.rangeCount === 0) { return; } const domRange = nativeSelection.getRangeAt(0); return domRange.getBoundingClientRect(); }; const ToolbarConnectorPluginInternal = ({ onSelectNode, }: { onSelectNode: (nodeKey: string) => void; }) => { const [editor] = useLexicalComposerContext(); const isMouseDownRef = useRef(false); // control toolbar state on data or selection updates const updateToolbar = useCallback(() => { const selection = $getSelection(); if ( $isRangeSelection(selection) && selection.getTextContent().length !== 0 && isMouseDownRef.current === false ) { const selectionRect = getSelectionClienRect(); const isBold = selection.hasFormat("bold"); const isItalic = selection.hasFormat("italic"); const isSuperscript = selection.hasFormat("superscript"); const isSubscript = selection.hasFormat("subscript"); const isLink = $isSelectedLink(selection); const isSpan = $getSpanNodes(selection).length !== 0; $textToolbar.set({ selectionRect, isBold, isItalic, isSuperscript, isSubscript, isLink, isSpan, }); } else { $textToolbar.set(undefined); } }, []); useEffect(() => { return subscribeScrollState({ onScrollStart: () => { // hide toolbar on scroll start preserving all data const textToolbar = $textToolbar.get(); if (textToolbar) { $textToolbar.set({ ...textToolbar, selectionRect: undefined, }); } }, onScrollEnd: () => { // restore toolbar with new position const textToolbar = $textToolbar.get(); if (textToolbar) { $textToolbar.set({ ...textToolbar, selectionRect: getSelectionClienRect(), }); } }, }); }, []); // prevent showing toolbar when select with mouse useEffect(() => { const onMouseDown = () => { isMouseDownRef.current = true; }; const onMouseUp = () => { isMouseDownRef.current = false; const editorState = editor.getEditorState(); editorState.read(() => { updateToolbar(); }); }; document.addEventListener("mousedown", onMouseDown); document.addEventListener("mouseup", onMouseUp); return () => { document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("mouseup", onMouseUp); }; }, [editor, updateToolbar]); useEffect(() => { // hide toolbar when editor is unmounted return () => { $textToolbar.set(undefined); }; }, []); useEffect(() => { return editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { updateToolbar(); return false; }, COMMAND_PRIORITY_LOW ); }, [editor, updateToolbar]); useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { updateToolbar(); }); }); }, [editor, updateToolbar]); useEffect(() => { return editor.registerCommand( TOGGLE_SPAN_COMMAND, () => { editor.update( () => { $toggleSpan(); }, { onUpdate: () => { const editorState = editor.getEditorState(); editorState.read(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { const spans = $getSpanNodes(selection); if (spans.length !== 0) { const [node] = spans; onSelectNode(node.getKey()); } } }); }, } ); return true; }, COMMAND_PRIORITY_EDITOR ); }, [editor, onSelectNode]); useEffect(() => { return editor.registerCommand( CLEAR_FORMAT_COMMAND, () => { editor.update(() => { $clearText(); }); return true; }, COMMAND_PRIORITY_EDITOR ); }, [editor]); return null; }; export const ToolbarConnectorPlugin = ({ onSelectNode, }: { onSelectNode: (nodeKey: string) => void; }) => { const [editor] = useLexicalComposerContext(); const [hasRootElement, setHasRootElement] = useState(false); useEffect(() => { const rootElement = editor.getRootElement(); /** * We don't set root element for VisuallyHidden nodes * and need to prevent Toolbar events in this case */ if (rootElement === null) { return; } setHasRootElement(true); activeEditor = editor; }, [editor]); if (hasRootElement === false) { return null; } return ; }; ================================================ FILE: apps/builder/app/canvas/features/webstudio-component/index.ts ================================================ export * from "./webstudio-component"; ================================================ FILE: apps/builder/app/canvas/features/webstudio-component/webstudio-component.test.tsx ================================================ /** * @vitest-environment jsdom */ import { describe, test, expect } from "vitest"; import { __testing__ } from "./webstudio-component"; const { computeComponentKey } = __testing__; describe("computeComponentKey - key generation logic", () => { test("prioritizes assetId over other props", () => { expect( computeComponentKey({ $webstudio$canvasOnly$assetId: "asset-123", src: "/image.jpg", defaultValue: "default", }) ).toBe("asset-123"); }); test("falls back to defaultValue when no assetId", () => { expect( computeComponentKey({ src: "/image.jpg", defaultValue: "default-value", }) ).toBe("default-value"); }); test("uses src when no assetId or defaultValue", () => { expect( computeComponentKey({ src: "/path/to/video.mp4", }) ).toBe("/path/to/video.mp4"); }); test("returns undefined when no relevant props", () => { expect(computeComponentKey({})).toBeUndefined(); }); test("handles null and undefined values", () => { expect( computeComponentKey({ src: null, defaultValue: undefined, }) ).toBeUndefined(); }); test("coerces defaultValue to string", () => { expect(computeComponentKey({ defaultValue: 42 })).toBe("42"); expect(computeComponentKey({ defaultValue: true })).toBe("true"); expect(computeComponentKey({ defaultValue: 0 })).toBe("0"); }); test("coerces src to string", () => { expect(computeComponentKey({ src: "string-src" })).toBe("string-src"); expect(computeComponentKey({ src: 123 })).toBe("123"); expect(computeComponentKey({ src: undefined })).toBeUndefined(); }); test("different assetIds produce different keys", () => { const key1 = computeComponentKey({ $webstudio$canvasOnly$assetId: "asset-123", src: "/image.jpg", }); const key2 = computeComponentKey({ $webstudio$canvasOnly$assetId: "asset-456", src: "/image.jpg", }); expect(key1).not.toBe(key2); }); test("different src values produce different keys", () => { const key1 = computeComponentKey({ src: "/assets/video1.mp4" }); const key2 = computeComponentKey({ src: "/assets/video2.mp4" }); expect(key1).not.toBe(key2); }); }); ================================================ FILE: apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx ================================================ import { useEffect, forwardRef, type ForwardedRef, useRef, useLayoutEffect, useMemo, Fragment, type ReactNode, type JSX, } from "react"; import { $getSelection, $isRangeSelection } from "lexical"; import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { mergeRefs } from "@react-aria/utils"; import type { Instance, Instances, Prop, WsComponentMeta, } from "@webstudio-is/sdk"; import { findTreeInstanceIds, collectionComponent, descendantComponent, blockComponent, blockTemplateComponent, getIndexesWithinAncestors, elementComponent, } from "@webstudio-is/sdk"; import { indexProperty, tagProperty } from "@webstudio-is/sdk/runtime"; import { idAttribute, componentAttribute, showAttribute, selectorIdAttribute, type AnyComponent, textContentAttribute, standardAttributesToReactProps, getCollectionEntries, } from "@webstudio-is/react-sdk"; import { rawTheme } from "@webstudio-is/design-system"; import { Input, Select, Textarea } from "@webstudio-is/sdk-components-react"; const computeComponentKey = (props: Record) => { const assetId = props.$webstudio$canvasOnly$assetId; const src = props.src; const defaultValue = props.defaultValue; return ( (typeof assetId === "string" ? assetId : undefined) ?? (defaultValue != null ? String(defaultValue) : undefined) ?? (src != null ? String(src) : undefined) ); }; export const __testing__ = { computeComponentKey }; import { $propValuesByInstanceSelectorWithMemoryProps, getIndexedInstanceId, $instances, $registeredComponentMetas, $selectedInstanceRenderState, findBlockSelector, $props, } from "~/shared/nano-states"; import { $textEditingInstanceSelector } from "~/shared/nano-states"; import { type InstanceSelector, areInstanceSelectorsEqual, } from "~/shared/tree-utils"; import { inflateInstance } from "~/canvas/inflator"; import { getIsVisuallyHidden } from "~/shared/visually-hidden"; import { serverSyncStore } from "~/shared/sync/sync-stores"; import { TextEditor } from "../text-editor"; import { $selectedPage, getInstanceKey, selectInstance, } from "~/shared/awareness"; import { createInstanceChildrenElements, type WebstudioComponentProps, } from "~/canvas/elements"; import { Block } from "../build-mode/block"; import { BlockTemplate } from "../build-mode/block-template"; import { editablePlaceholderAttribute, editingPlaceholderVariable, } from "~/canvas/shared/styles"; import { richTextPlaceholders } from "~/shared/content-model"; const ContentEditable = ({ placeholder, renderComponentWithRef, }: { placeholder: string | undefined; renderComponentWithRef: ( elementRef: ForwardedRef ) => JSX.Element; }) => { const [editor] = useLexicalComposerContext(); const ref = useRef(null); /** * useLayoutEffect to be sure that editor plugins on useEffect would have access to rootElement */ useLayoutEffect(() => { const rootElement = ref.current; if (rootElement == null) { return; } if (getIsVisuallyHidden(rootElement)) { return; } if (rootElement.tagName === "A") { if (window.getComputedStyle(rootElement).display === "inline-flex") { // Issue: tag doesn't work with inline-flex when the cursor is at the start or end of the text. // Solution: Inline-flex is not supported by Lexical. Use "inline" during editing. rootElement.style.display = "inline"; } } // Issue: ); }); export const ProfileMenu = ({ user, userPlanFeatures, }: { user: User; userPlanFeatures: UserPlanFeatures; }) => { const navigate = useNavigate(); const nameOrEmail = user.username ?? user.email ?? defaultUserName; const purchases = userPlanFeatures.purchases; const hasPaidPlan = purchases.length > 0; return ( {user.username ?? defaultUserName} {user.email} {purchases.length > 0 && ( <> Purchases )} {purchases.map((purchase, index) => purchase.subscriptionId ? ( navigate(userPlanSubscriptionPath(purchase.subscriptionId)) } > {purchase.planName} ) : ( {purchase.planName} ) )} {hasPaidPlan === false && ( { window.open("https://webstudio.is/pricing"); }} css={{ gap: theme.spacing[3] }} >
Upgrade
)} navigate(logoutPath())}> Sign Out
); }; ================================================ FILE: apps/builder/app/dashboard/projects/colors.ts ================================================ export const colors = Array.from({ length: 50 }, (_, i) => { const l = 55 + (i % 3) * 3; // Reduced variation in lightness (55-61%) to lower contrast const c = 0.14 + (i % 2) * 0.02; // Reduced variation in chroma (0.14-0.16) for balance const h = (i * 137.5) % 360; // Golden angle for pleasing hue distribution return `oklch(${l}% ${c.toFixed(2)} ${h.toFixed(1)})`; }); ================================================ FILE: apps/builder/app/dashboard/projects/project-card.tsx ================================================ import { useEffect, useState } from "react"; import { css, Flex, Text, truncate, theme, Tooltip, rawTheme, Link, Box, } from "@webstudio-is/design-system"; import { InfoCircleIcon } from "@webstudio-is/icons"; import type { DashboardProject } from "@webstudio-is/dashboard"; import { builderUrl } from "~/shared/router-utils"; import { ProjectDialogs, type DialogType } from "./project-dialogs"; import { ThumbnailLinkWithAbbr, ThumbnailLinkWithImage, } from "../shared/thumbnail"; import { Spinner } from "../shared/spinner"; import { Card, CardContent, CardFooter } from "../shared/card"; import type { User } from "~/shared/db/user.server"; import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server"; import { ProjectMenu } from "./project-menu"; import { formatDate } from "./utils"; const infoIconStyle = css({ flexShrink: 0 }); const PublishedLink = ({ domain, tabIndex, }: { domain: string; tabIndex: number; }) => { const publishedOrigin = `https://${domain}`; return ( {new URL(publishedOrigin).host} ); }; type ProjectCardProps = { project: DashboardProject; userPlanFeatures: UserPlanFeatures; publisherHost: string; projectsTags: User["projectsTags"]; }; export const ProjectCard = ({ project: { id, title, domain, isPublished, createdAt, latestBuildVirtual, previewImageAsset, tags, domainsVirtual, }, userPlanFeatures, publisherHost, projectsTags, ...props }: ProjectCardProps) => { // Determine which domain to display: custom domain if available, otherwise wstd subdomain const customDomain = domainsVirtual?.find( (d: { domain: string; status: string; verified: boolean }) => d.status === "ACTIVE" && d.verified )?.domain; const displayDomain = customDomain ?? `${domain}.${publisherHost}`; const [openDialog, setOpenDialog] = useState(); const [isHidden, setIsHidden] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); // Makes sure there are no project tags that reference deleted User tags. // We are not deleting project tag from project.tags when deleting User tags. const projectTagsIds = (tags || []) .map((tagId) => { const tag = projectsTags.find((tag) => tag.id === tagId); return tag ? tag.id : undefined; }) .filter(Boolean) as string[]; useEffect(() => { const linkPath = builderUrl({ origin: window.origin, projectId: id }); const handleNavigate = (event: NavigateEvent) => { if (event.destination.url === linkPath) { setIsTransitioning(true); } }; if (window.navigation === undefined) { return; } window.navigation.addEventListener("navigate", handleNavigate); return () => { window.navigation.removeEventListener("navigate", handleNavigate); }; }, [id]); const linkPath = builderUrl({ origin: window.origin, projectId: id }); return ( ); }; ================================================ FILE: apps/builder/app/dashboard/projects/project-dialogs.tsx ================================================ import { useRevalidator } from "@remix-run/react"; import { useEffect, useState, type JSX } from "react"; import { Box, Button, Flex, Label, Text, InputField, DialogActions, Dialog as BaseDialog, DialogTrigger, DialogContent as DialogContentBase, DialogTitle, DialogClose, DialogDescription, theme, } from "@webstudio-is/design-system"; import { PlusIcon } from "@webstudio-is/icons"; import { Title } from "@webstudio-is/project"; import { builderUrl } from "~/shared/router-utils"; import { ShareProjectContainer } from "~/shared/share-project"; import { trpcClient } from "~/shared/trpc/trpc-client"; import type { DashboardProject } from "@webstudio-is/dashboard"; import { ProjectSettingsDialog, type SectionName, } from "~/shared/project-settings"; import type { User } from "~/shared/db/user.server"; import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server"; import { TagsDialog } from "./tags"; import { destroyClientSync, initializeClientSync, } from "~/shared/sync/sync-client"; import { $userPlanFeatures } from "~/shared/nano-states"; export type DialogType = "rename" | "delete" | "share" | "tags" | "settings"; type DialogProps = { title: string; children: JSX.Element | Array; trigger?: JSX.Element; onOpenChange?: (open: boolean) => void; isOpen?: boolean; }; const Dialog = ({ title, children, trigger, onOpenChange, isOpen, }: DialogProps) => { return ( {trigger && {trigger}} {children} {title} ); }; const DialogContent = ({ onSubmit, onChange, placeholder, errors, primaryButton, title, description, label, width, }: { onSubmit: (data: { title: string }) => void; onChange?: (data: { title: string }) => void; errors?: string; placeholder?: string; title?: string; label: string | JSX.Element; description?: string; primaryButton: JSX.Element; width?: string; }) => { return (
{ event.preventDefault(); const formData = new FormData(event.currentTarget as HTMLFormElement); const title = String(formData.get("title") ?? "").trim(); onSubmit({ title }); }} > {description && ( {description} )} {typeof label === "string" ? : label} { onChange?.({ title: event.currentTarget.value }); }} /> {errors && {errors}} {primaryButton}
); }; const useCreateProject = () => { const { send, state } = trpcClient.dashboardProject.create.useMutation(); const [errors, setErrors] = useState(); const handleSubmit = ({ title }: { title: string }) => { const parsed = Title.safeParse(title); const errors = "error" in parsed ? parsed.error?.issues.map((issue) => issue.message).join("\n") : undefined; setErrors(errors); if (parsed.success) { send({ title }, (data) => { if (data?.id) { window.location.href = builderUrl({ origin: window.origin, projectId: data.id, }); } }); } }; const handleOpenChange = () => { setErrors(undefined); }; return { handleSubmit, handleOpenChange, state, errors, }; }; export const CreateProject = ({ buttonText = "New blank project", }: { buttonText?: string; }) => { const { handleSubmit, handleOpenChange, state, errors } = useCreateProject(); return ( }>{buttonText}} onOpenChange={handleOpenChange} > Create Project } /> ); }; const useRenameProject = ({ projectId, onOpenChange, }: { projectId: string; onOpenChange: (isOpen: boolean) => void; }) => { const { send, state } = trpcClient.dashboardProject.rename.useMutation(); const [errors, setErrors] = useState(); const revalidator = useRevalidator(); const handleSubmit = ({ title }: { title: string }) => { const parsed = Title.safeParse(title); const errors = "error" in parsed ? parsed.error?.issues.map((issue) => issue.message).join("\n") : undefined; setErrors(errors); if (parsed.success) { send({ projectId, title }, () => { revalidator.revalidate(); }); onOpenChange(false); } }; return { handleSubmit, errors, state, }; }; export const RenameProjectDialog = ({ isOpen, title, projectId, onOpenChange, }: { isOpen: boolean; title: string; projectId: string; onOpenChange: (isOpen: boolean) => void; }) => { const { handleSubmit, errors, state } = useRenameProject({ projectId, onOpenChange, }); return ( Rename Project } /> ); }; const useDeleteProject = ({ projectId, title, onOpenChange, onHiddenChange, }: { projectId: string; title: string; onOpenChange: (isOpen: boolean) => void; onHiddenChange: (isHidden: boolean) => void; }) => { const { send, data, state } = trpcClient.dashboardProject.delete.useMutation(); const [isMatch, setIsMatch] = useState(false); const errors = data && "errors" in data ? data.errors : undefined; const revalidator = useRevalidator(); useEffect(() => { if (errors) { onOpenChange(true); onHiddenChange(false); } }, [errors, onOpenChange, onHiddenChange]); const handleSubmit = () => { send({ projectId }, () => { revalidator.revalidate(); }); onHiddenChange(true); onOpenChange(false); }; const handleChange = ({ title: currentTitle }: { title: string }) => { setIsMatch( currentTitle.trim().toLocaleLowerCase() === title.trim().toLocaleLowerCase() ); }; return { handleSubmit, handleChange, errors, isMatch, state, }; }; export const DeleteProjectDialog = ({ isOpen, title, projectId, onOpenChange, onHiddenChange, }: { isOpen: boolean; title: string; projectId: string; onOpenChange: (isOpen: boolean) => void; onHiddenChange: (isHidden: boolean) => void; }) => { const { handleSubmit, handleChange, errors, isMatch, state } = useDeleteProject({ projectId, title, onOpenChange, onHiddenChange, }); return ( Confirm by typing {` ${title} `} below. } description="This project and its styles, pages and images will be deleted permanently." primaryButton={ } width={theme.spacing["33"]} /> ); }; export const useDuplicateProject = (projectId: string) => { const { send } = trpcClient.dashboardProject.clone.useMutation(); const revalidator = useRevalidator(); return () => { send({ projectId }, () => { revalidator.revalidate(); }); }; }; export const ShareProjectDialog = ({ isOpen, onOpenChange, projectId, }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; projectId: string; }) => { return ( ); }; /** * Container component that manages data loading for ProjectSettingsDialog. * Handles sync initialization when the dialog is opened from the dashboard. */ const ProjectSettingsDialogContainer = ({ projectId, onOpenChange, isOpen, userPlanFeatures, }: { projectId: string; onOpenChange: (isOpen: boolean) => void; isOpen: boolean; userPlanFeatures: UserPlanFeatures; }) => { const [currentSection, setCurrentSection] = useState< SectionName | undefined >(); const [loadingState, setLoadingState] = useState< "idle" | "loading" | "loaded" >("idle"); // Set section and user plan features when dialog opens useEffect(() => { if (isOpen) { setCurrentSection("general"); $userPlanFeatures.set(userPlanFeatures); setLoadingState("loading"); } else { setCurrentSection(undefined); setLoadingState("idle"); // Reset data stores and stop sync when dialog closes destroyClientSync(); } }, [isOpen, userPlanFeatures]); // Initialize sync when settings dialog is opened useEffect(() => { if (!isOpen) { return; } // Initialize sync which will load data, start project sync, and start polling const controller = new AbortController(); initializeClientSync({ projectId, authPermit: "own", // Dashboard projects are always owned by the current user signal: controller.signal, onReady() { setLoadingState("loaded"); }, }); return () => { controller.abort("settings-closed"); }; }, [isOpen, projectId]); return ( ); }; type ProjectDialogsProps = { projectId: string; title: string; tags: DashboardProject["tags"]; openDialog: DialogType | undefined; onOpenDialogChange: (dialog: DialogType | undefined) => void; onHiddenChange: (isHidden: boolean) => void; userPlanFeatures: UserPlanFeatures; projectsTags: User["projectsTags"]; }; /** * Shared component that handles all project dialogs. */ export const ProjectDialogs = ({ projectId, title, tags, openDialog, onOpenDialogChange, onHiddenChange, userPlanFeatures, projectsTags, }: ProjectDialogsProps) => { const projectTagsIds = (tags || []) .map((tagId) => { const tag = projectsTags.find((tag) => tag.id === tagId); return tag ? tag.id : undefined; }) .filter(Boolean) as string[]; return ( <> onOpenDialogChange(open ? "rename" : undefined)} title={title} projectId={projectId} /> onOpenDialogChange(open ? "delete" : undefined)} onHiddenChange={onHiddenChange} title={title} projectId={projectId} /> onOpenDialogChange(open ? "share" : undefined)} projectId={projectId} /> onOpenDialogChange(open ? "tags" : undefined)} /> onOpenDialogChange(open ? "settings" : undefined) } isOpen={openDialog === "settings"} userPlanFeatures={userPlanFeatures} /> ); }; ================================================ FILE: apps/builder/app/dashboard/projects/project-menu.tsx ================================================ import { useState } from "react"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, IconButton, theme, } from "@webstudio-is/design-system"; import { EllipsesIcon } from "@webstudio-is/icons"; import type { DialogType } from "./project-dialogs"; import { useDuplicateProject } from "./project-dialogs"; import { builderUrl } from "~/shared/router-utils"; type ProjectMenuProps = { projectId: string; onOpenChange: (dialog: DialogType) => void; }; export const ProjectMenu = ({ projectId, onOpenChange }: ProjectMenuProps) => { const [isOpen, setIsOpen] = useState(false); const handleDuplicateProject = useDuplicateProject(projectId); const handleOpenInSafeMode = () => { window.location.href = builderUrl({ origin: window.origin, projectId, safemode: true, }); }; return ( Duplicate onOpenChange("rename")}> Rename onOpenChange("share")}> Share onOpenChange("delete")}> Delete onOpenChange("tags")}> Tags onOpenChange("settings")}> Settings Open in safe mode ); }; ================================================ FILE: apps/builder/app/dashboard/projects/projects-list.tsx ================================================ import { useState } from "react"; import { Flex, Text, theme, Link, css, List, ListItem, IconButton, } from "@webstudio-is/design-system"; import { ChevronUpIcon, ChevronDownIcon } from "@webstudio-is/icons"; import type { DashboardProject } from "@webstudio-is/dashboard"; import { builderUrl } from "~/shared/router-utils"; import { ProjectDialogs, type DialogType } from "./project-dialogs"; import type { User } from "~/shared/db/user.server"; import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server"; import { ProjectMenu } from "./project-menu"; import { formatDate } from "./utils"; import type { SortField, SortOrder } from "./sort"; const tableStyles = css({ display: "table", width: "100%", tableLayout: "fixed", borderCollapse: "collapse", marginBottom: theme.spacing[13], minWidth: 550, flexGrow: 1, '& [role="rowgroup"]': { display: "table-row-group", }, '& [role="row"]': { display: "table-row", position: "relative", "&:focus-visible": { outline: `1px solid ${theme.colors.borderFocus}`, }, '&:has([role="cell"]):hover': { background: theme.colors.backgroundHover, }, }, '& [role="columnheader"]': { display: "table-cell", padding: theme.spacing[5], paddingBottom: theme.spacing[3], textAlign: "left", borderBottom: `1px solid ${theme.colors.borderMain}`, "&:first-child": { width: "35%", }, "&:nth-child(2), &:nth-child(3), &:nth-child(4)": { width: "20%", }, "&:last-child": { width: "5%", }, }, '& [role="cell"]': { display: "table-cell", padding: theme.spacing[5], verticalAlign: "middle", }, }); type ProjectsListItemProps = { project: DashboardProject; userPlanFeatures: UserPlanFeatures; publisherHost: string; projectsTags: User["projectsTags"]; }; export const ProjectsListItem = ({ project: { id, title, domain, isPublished, createdAt, latestBuildVirtual, tags, domainsVirtual, }, userPlanFeatures, publisherHost, projectsTags, }: ProjectsListItemProps) => { const customDomain = domainsVirtual?.find( (d: { domain: string; status: string; verified: boolean }) => d.status === "ACTIVE" && d.verified )?.domain; const displayDomain = customDomain ?? `${domain}.${publisherHost}`; const [openDialog, setOpenDialog] = useState(); const [isHidden, setIsHidden] = useState(false); if (isHidden) { return; } const linkPath = builderUrl({ origin: window.origin, projectId: id }); return ( <>
{title} {isPublished && ( {displayDomain} )}
{latestBuildVirtual?.updatedAt ? formatDate(latestBuildVirtual.updatedAt) : formatDate(createdAt)}
{isPublished && latestBuildVirtual ? formatDate(latestBuildVirtual.createdAt) : "Not published"}
{formatDate(createdAt)}
); }; type ProjectsListProps = { projects: Array; userPlanFeatures: UserPlanFeatures; publisherHost: string; projectsTags: User["projectsTags"]; sortBy?: SortField; sortOrder?: SortOrder; onSortChange: (field: SortField) => void; }; const columns: Array<{ field: SortField; label: string } | null> = [ { field: "title", label: "Name" }, { field: "updatedAt", label: "Last modified" }, { field: "publishedAt", label: "Last published" }, { field: "createdAt", label: "Date created" }, null, // Actions column (no sorting) ]; export const ProjectsList = ({ projects, userPlanFeatures, publisherHost, projectsTags, sortBy, sortOrder, onSortChange, }: ProjectsListProps) => { return (
{columns.map((column, index) => (
{column ? ( {column.label} onSortChange(column.field)} css={{ opacity: sortBy === column.field ? 1 : 0.5 }} aria-label={`Sort by ${column.label}${ sortBy === column.field ? sortOrder === "asc" ? ", sorted ascending" : ", sorted descending" : ", not sorted" }`} aria-describedby={`sort-${column.field}-label`} tabIndex={-1} > {sortBy === column.field && sortOrder === "asc" ? ( ) : ( )} ) : undefined}
))}
{projects.map((project) => ( ))}
); }; ================================================ FILE: apps/builder/app/dashboard/projects/projects.tsx ================================================ import { Flex, Grid, List, ListItem, Text, rawTheme, theme, ToggleGroup, ToggleGroupButton, } from "@webstudio-is/design-system"; import { RepeatGridIcon, ListViewIcon } from "@webstudio-is/icons"; import type { DashboardProject } from "@webstudio-is/dashboard"; import { ProjectCard } from "./project-card"; import { CreateProject } from "./project-dialogs"; import { Header, Main } from "../shared/layout"; import { useSearchParams } from "react-router-dom"; import { setIsSubsetOf } from "~/shared/shim"; import type { User } from "~/shared/db/user.server"; import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server"; import { Tag } from "./tags"; import { SortSelect, sortProjects, type SortState, type SortField, } from "./sort"; import { ProjectsList } from "./projects-list"; export const ProjectsGrid = ({ projects, userPlanFeatures, publisherHost, projectsTags, }: ProjectsProps) => { return ( {projects.map((project) => { return ( ); })} ); }; type ProjectsProps = { projects: Array; userPlanFeatures: UserPlanFeatures; publisherHost: string; projectsTags: User["projectsTags"]; }; export const Projects = (props: ProjectsProps) => { const [searchParams, setSearchParams] = useSearchParams(); const selectedTags = searchParams.getAll("tag"); const viewMode = (searchParams.get("view") as "grid" | "list") ?? "grid"; const sortState: SortState = { sortBy: searchParams.get("sortBy") as SortState["sortBy"], order: searchParams.get("order") as SortState["order"], }; const handleSortChange = (newSortState: Required) => { const newParams = new URLSearchParams(searchParams); newParams.set("sortBy", newSortState.sortBy); newParams.set("order", newSortState.order); setSearchParams(newParams); }; const handleTableSort = (field: SortField) => { const currentSortBy = sortState.sortBy ?? "updatedAt"; const currentOrder = sortState.order ?? "desc"; // If clicking the same field, toggle the order if (field === currentSortBy) { const newOrder = currentOrder === "asc" ? "desc" : "asc"; handleSortChange({ sortBy: field, order: newOrder }); } else { // When switching to a new field, use smart defaults const newOrder = field === "title" ? "asc" : "desc"; handleSortChange({ sortBy: field, order: newOrder }); } }; const handleViewChange = (value: string) => { const newParams = new URLSearchParams(searchParams); if (value === "grid") { newParams.delete("view"); } else { newParams.set("view", value); } setSearchParams(newParams); }; // Filter by tags let projects = props.projects; if (selectedTags.length > 0) { projects = projects.filter((project) => setIsSubsetOf(new Set(selectedTags), new Set(project.tags)) ); } projects = sortProjects(projects, sortState); return (
Projects
{props.projectsTags.map((tag, index) => { return ( {tag.label} ); })} {projects.length === 0 ? ( No projects found ) : viewMode === "grid" ? ( ) : ( )}
); }; ================================================ FILE: apps/builder/app/dashboard/projects/sort.test.ts ================================================ /** * Tests for the sortProjects function and sort order semantics. * * This test suite covers: * 1. All sort fields (title, createdAt, updatedAt, publishedAt) * 2. Both sort orders (asc, desc) * 3. Special cases (unpublished projects, fallback to createdAt) * 4. Immutability guarantees * 5. Edge cases (empty arrays, single items, identical values) * 6. Order semantics: * - Alphabetical: asc = A→Z, desc = Z→A * - Dates: asc = Oldest first, desc = Newest first */ import { describe, test, expect } from "vitest"; import { sortProjects } from "./sort"; import type { DashboardProject } from "@webstudio-is/dashboard"; type LatestBuildVirtual = NonNullable; const createMockProject = ( overrides: Partial ): DashboardProject => { return { id: "project-1", title: "Test Project", domain: "test", isDeleted: false, createdAt: "2024-01-01T00:00:00.000Z", marketplaceApprovalStatus: "UNLISTED", latestBuildVirtual: null, previewImageAsset: null, tags: [], isPublished: false, ...overrides, } as DashboardProject; }; describe("sortProjects", () => { describe("sort by title", () => { test("sorts alphabetically in ascending order", () => { const projects = [ createMockProject({ id: "1", title: "Zebra" }), createMockProject({ id: "2", title: "Apple" }), createMockProject({ id: "3", title: "Mango" }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "asc" }); expect(sorted.map((p) => p.title)).toEqual(["Apple", "Mango", "Zebra"]); }); test("sorts alphabetically in descending order", () => { const projects = [ createMockProject({ id: "1", title: "Apple" }), createMockProject({ id: "2", title: "Zebra" }), createMockProject({ id: "3", title: "Mango" }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "desc" }); expect(sorted.map((p) => p.title)).toEqual(["Zebra", "Mango", "Apple"]); }); test("handles case-insensitive sorting", () => { const projects = [ createMockProject({ id: "1", title: "zebra" }), createMockProject({ id: "2", title: "Apple" }), createMockProject({ id: "3", title: "MANGO" }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "asc" }); expect(sorted.map((p) => p.title)).toEqual(["Apple", "MANGO", "zebra"]); }); }); describe("sort by createdAt", () => { test("sorts by creation date in ascending order (oldest first)", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-03-01T00:00:00.000Z" }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z" }), createMockProject({ id: "3", createdAt: "2024-02-01T00:00:00.000Z" }), ]; const sorted = sortProjects(projects, { sortBy: "createdAt", order: "asc", }); expect(sorted.map((p) => p.id)).toEqual(["2", "3", "1"]); }); test("sorts by creation date in descending order (newest first)", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z" }), createMockProject({ id: "2", createdAt: "2024-03-01T00:00:00.000Z" }), createMockProject({ id: "3", createdAt: "2024-02-01T00:00:00.000Z" }), ]; const sorted = sortProjects(projects, { sortBy: "createdAt", order: "desc", }); expect(sorted.map((p) => p.id)).toEqual(["2", "3", "1"]); }); }); describe("sort by updatedAt (last modified)", () => { test("sorts by latest build updatedAt when available", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { buildId: "build1", projectId: "1", domainsVirtualId: "", domain: "test", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-03-01T00:00:00.000Z", publishStatus: "PUBLISHED", }, }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { buildId: "build2", projectId: "2", domainsVirtualId: "", domain: "test", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-15T00:00:00.000Z", publishStatus: "PUBLISHED", }, }), createMockProject({ id: "3", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { buildId: "build3", projectId: "3", domainsVirtualId: "", domain: "test", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-02-01T00:00:00.000Z", publishStatus: "PUBLISHED", }, }), ]; const sorted = sortProjects(projects, { sortBy: "updatedAt", order: "desc", }); expect(sorted.map((p) => p.id)).toEqual(["1", "3", "2"]); }); test("falls back to createdAt when latestBuildVirtual is null", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-03-01T00:00:00.000Z", latestBuildVirtual: null, }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { buildId: "build2", projectId: "2", domainsVirtualId: "", domain: "test", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-02-01T00:00:00.000Z", publishStatus: "PUBLISHED", }, }), createMockProject({ id: "3", createdAt: "2024-02-01T00:00:00.000Z", latestBuildVirtual: null, }), ]; const sorted = sortProjects(projects, { sortBy: "updatedAt", order: "desc", }); // Project 1: March 1 (createdAt fallback) // Project 2: Feb 1 (latestBuildVirtual.updatedAt) // Project 3: Feb 1 (createdAt fallback) expect(sorted.map((p) => p.id)).toEqual(["1", "2", "3"]); }); test("sorts in ascending order (oldest first)", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { buildId: "build1", projectId: "1", domainsVirtualId: "", domain: "test", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-03-01T00:00:00.000Z", publishStatus: "PUBLISHED", }, }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { buildId: "build2", projectId: "2", domainsVirtualId: "", domain: "test", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-15T00:00:00.000Z", publishStatus: "PUBLISHED", }, }), ]; const sorted = sortProjects(projects, { sortBy: "updatedAt", order: "asc", }); expect(sorted.map((p) => p.id)).toEqual(["2", "1"]); }); }); describe("sort by publishedAt (date published)", () => { test("sorts published projects by publish date", () => { const projects = [ createMockProject({ id: "1", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-03-01T00:00:00.000Z", } as LatestBuildVirtual, }), createMockProject({ id: "2", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-01-01T00:00:00.000Z", } as LatestBuildVirtual, }), createMockProject({ id: "3", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-02-01T00:00:00.000Z", } as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects, { sortBy: "publishedAt", order: "desc", }); expect(sorted.map((p) => p.id)).toEqual(["1", "3", "2"]); }); test("puts unpublished projects at the end in descending order", () => { const projects = [ createMockProject({ id: "1", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-02-01T00:00:00.000Z", } as LatestBuildVirtual, }), createMockProject({ id: "2", isPublished: false, latestBuildVirtual: null, }), createMockProject({ id: "3", isPublished: false, latestBuildVirtual: { publishStatus: "PENDING", createdAt: "2024-03-01T00:00:00.000Z", } as LatestBuildVirtual, }), createMockProject({ id: "4", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-01-01T00:00:00.000Z", } as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects, { sortBy: "publishedAt", order: "desc", }); // Unpublished projects (isPublished: false) come at the end // Published projects sorted by date descending expect(sorted.map((p) => p.id)).toEqual(["1", "4", "2", "3"]); }); test("puts unpublished projects at the end in ascending order", () => { const projects = [ createMockProject({ id: "1", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-02-01T00:00:00.000Z", } as LatestBuildVirtual, }), createMockProject({ id: "2", isPublished: false, latestBuildVirtual: null, }), createMockProject({ id: "3", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-01-01T00:00:00.000Z", } as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects, { sortBy: "publishedAt", order: "asc", }); // Published projects first (sorted by date ascending), then unpublished expect(sorted.map((p) => p.id)).toEqual(["3", "1", "2"]); }); test("maintains order for multiple unpublished projects", () => { const projects = [ createMockProject({ id: "1", isPublished: false, latestBuildVirtual: null, }), createMockProject({ id: "2", isPublished: false, latestBuildVirtual: { publishStatus: "PENDING", } as LatestBuildVirtual, }), createMockProject({ id: "3", isPublished: false, latestBuildVirtual: null, }), ]; const sorted = sortProjects(projects, { sortBy: "publishedAt", order: "desc", }); expect(sorted.map((p) => p.id)).toEqual(["1", "2", "3"]); }); }); describe("immutability", () => { test("does not mutate the original array", () => { const projects = [ createMockProject({ id: "1", title: "B" }), createMockProject({ id: "2", title: "A" }), ]; const originalOrder = projects.map((p) => p.id); sortProjects(projects, { sortBy: "title", order: "asc" }); expect(projects.map((p) => p.id)).toEqual(originalOrder); }); test("returns a new array", () => { const projects = [ createMockProject({ id: "1", title: "A" }), createMockProject({ id: "2", title: "B" }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "asc" }); expect(sorted).not.toBe(projects); }); }); describe("edge cases", () => { test("handles empty array", () => { const sorted = sortProjects([], { sortBy: "title", order: "asc" }); expect(sorted).toEqual([]); }); test("handles single project", () => { const projects = [createMockProject({ id: "1", title: "Only" })]; const sorted = sortProjects(projects, { sortBy: "title", order: "asc" }); expect(sorted).toHaveLength(1); expect(sorted[0].id).toBe("1"); }); test("handles projects with identical values", () => { const projects = [ createMockProject({ id: "1", title: "Same", createdAt: "2024-01-01T00:00:00.000Z", }), createMockProject({ id: "2", title: "Same", createdAt: "2024-01-01T00:00:00.000Z", }), createMockProject({ id: "3", title: "Same", createdAt: "2024-01-01T00:00:00.000Z", }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "asc" }); expect(sorted).toHaveLength(3); }); }); describe("default sort options", () => { test("defaults to updatedAt desc when no sort state provided", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-01-15T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-03-01T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), createMockProject({ id: "3", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-02-01T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects); // Default: updatedAt desc (newest first) expect(sorted.map((p) => p.id)).toEqual(["2", "3", "1"]); }); test("defaults to updatedAt when only order is provided", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-01-15T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-03-01T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects, { order: "asc" }); // updatedAt asc (oldest first) expect(sorted.map((p) => p.id)).toEqual(["1", "2"]); }); test("defaults to desc order when only sortBy is provided", () => { const projects = [ createMockProject({ id: "1", title: "Apple" }), createMockProject({ id: "2", title: "Zebra" }), createMockProject({ id: "3", title: "Mango" }), ]; const sorted = sortProjects(projects, { sortBy: "title" }); // title desc (Z→A) expect(sorted.map((p) => p.title)).toEqual(["Zebra", "Mango", "Apple"]); }); test("uses provided sortBy and order when both are specified", () => { const projects = [ createMockProject({ id: "1", title: "Zebra" }), createMockProject({ id: "2", title: "Apple" }), createMockProject({ id: "3", title: "Mango" }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "asc" }); // title asc (A→Z) expect(sorted.map((p) => p.title)).toEqual(["Apple", "Mango", "Zebra"]); }); test("handles empty sort state object", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-01-15T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-03-01T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects, {}); // Default: updatedAt desc expect(sorted.map((p) => p.id)).toEqual(["2", "1"]); }); }); describe("order semantics", () => { describe("alphabetical sorting", () => { test("asc order produces A→Z sorting", () => { const projects = [ createMockProject({ id: "1", title: "Zebra" }), createMockProject({ id: "2", title: "Apple" }), createMockProject({ id: "3", title: "Mango" }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "asc", }); // A→Z: Apple, Mango, Zebra expect(sorted.map((p) => p.title)).toEqual(["Apple", "Mango", "Zebra"]); }); test("desc order produces Z→A sorting", () => { const projects = [ createMockProject({ id: "1", title: "Apple" }), createMockProject({ id: "2", title: "Zebra" }), createMockProject({ id: "3", title: "Mango" }), ]; const sorted = sortProjects(projects, { sortBy: "title", order: "desc", }); // Z→A: Zebra, Mango, Apple expect(sorted.map((p) => p.title)).toEqual(["Zebra", "Mango", "Apple"]); }); }); describe("date sorting", () => { test("desc order produces newest first for createdAt", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z" }), createMockProject({ id: "2", createdAt: "2024-03-01T00:00:00.000Z" }), createMockProject({ id: "3", createdAt: "2024-02-01T00:00:00.000Z" }), ]; const sorted = sortProjects(projects, { sortBy: "createdAt", order: "desc", }); // Newest first: March, February, January expect(sorted.map((p) => p.id)).toEqual(["2", "3", "1"]); }); test("asc order produces oldest first for createdAt", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-03-01T00:00:00.000Z" }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z" }), createMockProject({ id: "3", createdAt: "2024-02-01T00:00:00.000Z" }), ]; const sorted = sortProjects(projects, { sortBy: "createdAt", order: "asc", }); // Oldest first: January, February, March expect(sorted.map((p) => p.id)).toEqual(["2", "3", "1"]); }); test("desc order produces newest first for updatedAt", () => { const projects = [ createMockProject({ id: "1", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-01-15T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), createMockProject({ id: "2", createdAt: "2024-01-01T00:00:00.000Z", latestBuildVirtual: { updatedAt: "2024-03-01T00:00:00.000Z", } as unknown as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects, { sortBy: "updatedAt", order: "desc", }); // Newest first: March update, January update expect(sorted.map((p) => p.id)).toEqual(["2", "1"]); }); test("desc order produces newest first for publishedAt", () => { const projects = [ createMockProject({ id: "1", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-01-01T00:00:00.000Z", } as LatestBuildVirtual, }), createMockProject({ id: "2", isPublished: true, latestBuildVirtual: { publishStatus: "PUBLISHED", createdAt: "2024-03-01T00:00:00.000Z", } as LatestBuildVirtual, }), ]; const sorted = sortProjects(projects, { sortBy: "publishedAt", order: "desc", }); // Newest first: March publish, January publish expect(sorted.map((p) => p.id)).toEqual(["2", "1"]); }); }); }); }); ================================================ FILE: apps/builder/app/dashboard/projects/sort.tsx ================================================ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuLabel, Button, MenuCheckedIcon, } from "@webstudio-is/design-system"; import { ChevronDownIcon, CalendarIcon, UploadIcon, ArrowDownAZIcon, ArrowDownZAIcon, } from "@webstudio-is/icons"; import type { DashboardProject } from "@webstudio-is/dashboard"; export type SortField = "createdAt" | "title" | "updatedAt" | "publishedAt"; export type SortOrder = "asc" | "desc"; export type SortState = { sortBy?: SortField; order?: SortOrder; }; export const sortProjects = ( projects: Array, sortState: SortState = {} ) => { const sortBy = sortState.sortBy ?? "updatedAt"; const order = sortState.order ?? "desc"; const sorted = [...projects]; sorted.sort((a, b) => { let comparison = 0; if (sortBy === "title") { const aTitle = a.title ?? ""; const bTitle = b.title ?? ""; comparison = aTitle.localeCompare(bTitle); } else if (sortBy === "createdAt") { const aCreated = a.createdAt ?? ""; const bCreated = b.createdAt ?? ""; comparison = new Date(aCreated).getTime() - new Date(bCreated).getTime(); } else if (sortBy === "updatedAt") { // Uses Build's updatedAt field from latestBuildVirtual view, falls back to project createdAt const aUpdated = a.latestBuildVirtual?.updatedAt || a.createdAt || ""; const bUpdated = b.latestBuildVirtual?.updatedAt || b.createdAt || ""; comparison = new Date(aUpdated).getTime() - new Date(bUpdated).getTime(); } else if (sortBy === "publishedAt") { // Sort by published date, putting unpublished projects at the end const aPublished = a.isPublished ? a.latestBuildVirtual?.createdAt : undefined; const bPublished = b.isPublished ? b.latestBuildVirtual?.createdAt : undefined; // If both are published, compare dates normally (will be reversed by order) if (aPublished && bPublished) { comparison = new Date(aPublished).getTime() - new Date(bPublished).getTime(); } else if (aPublished && !bPublished) { // Published should always come before unpublished, regardless of order // In asc: -1 means a before b (correct) // In desc: we return -(-1) = 1, meaning a after b (wrong!) // So we need to account for the order reversal comparison = order === "asc" ? -1 : 1; } else if (!aPublished && bPublished) { // Unpublished should always come after published, regardless of order comparison = order === "asc" ? 1 : -1; } else { comparison = 0; // both unpublished, maintain order } } return order === "asc" ? comparison : -comparison; }); return sorted; }; type SortSelectProps = { value: SortState; onValueChange: (value: Required) => void; }; export const SortSelect = ({ value, onValueChange }: SortSelectProps) => { const sortBy = value.sortBy ?? "updatedAt"; const order = value.order ?? "desc"; const sortLabel = sortBy === "createdAt" ? "Date created" : sortBy === "title" ? "Alphabetical" : sortBy === "publishedAt" ? "Date published" : "Last modified"; const sortIcon = sortBy === "createdAt" ? ( ) : sortBy === "title" ? ( order === "asc" ? ( ) : ( ) ) : sortBy === "publishedAt" ? ( ) : ( ); const handleSortChange = (newSortBy: SortField, newOrder: SortOrder) => { // When switching to alphabetical sorting, default to A→Z (asc) // When switching to date sorting, default to newest first (desc) if (newSortBy !== sortBy) { onValueChange({ sortBy: newSortBy, order: newSortBy === "title" ? "asc" : "desc", }); } else { onValueChange({ sortBy: newSortBy, order: newOrder }); } }; return ( Sort handleSortChange(value as SortField, order)} > }> Alphabetical }> Date created }> Last modified }> Date published Order handleSortChange(sortBy, value as SortOrder) } > {sortBy === "title" ? ( <> }> A→Z }> Z→A ) : ( <> }> Newest first }> Oldest first )} ); }; ================================================ FILE: apps/builder/app/dashboard/projects/tags.tsx ================================================ import { useRevalidator, useSearchParams } from "react-router-dom"; import { useState, type ComponentProps } from "react"; import { Text, theme, Dialog, DialogContent, DialogTitle, Button, DialogActions, DialogClose, Checkbox, CheckboxAndLabel, Label, InputField, DialogTitleActions, Grid, List, ListItem, Flex, DropdownMenu, DropdownMenuTrigger, SmallIconButton, DropdownMenuContent, DropdownMenuItem, } from "@webstudio-is/design-system"; import { nativeClient } from "~/shared/trpc/trpc-client"; import type { User } from "~/shared/db/user.server"; import { nanoid } from "nanoid"; import { EllipsesIcon, SpinnerIcon } from "@webstudio-is/icons"; import { colors } from "./colors"; type DeleteConfirmationDialogProps = { onClose: () => void; onConfirm: () => void; question: string; }; const DeleteConfirmationDialog = ({ onClose, onConfirm, question, }: DeleteConfirmationDialogProps) => { return ( { if (isOpen === false) { onClose(); } }} > {question} Delete confirmation ); }; const TagsList = ({ projectId, projectsTags, projectTagsIds, onEdit, }: { projectId: string; projectsTags: User["projectsTags"]; projectTagsIds: string[]; onEdit: (tagId: string) => void; }) => { const revalidator = useRevalidator(); const [deleteConfirmationTagId, setDeleteConfirmationTagId] = useState(); return (
{ event.preventDefault(); const formData = new FormData(event.currentTarget); const tagsIds = formData.getAll("tagId") as string[]; await nativeClient.project.updateTags.mutate({ projectId, tags: tagsIds, }); revalidator.revalidate(); }} > {projectsTags .sort((a, b) => a.label.localeCompare(b.label)) .map((tag, index) => ( {}} index={index} key={tag.id}> {/* a11y is completely broken here focus is not restored to button invoker @todo fix it eventually and consider restoring from closed value preview dialog */} } /> event.preventDefault()} align="end" > { onEdit(tag.id); }} > Edit { setDeleteConfirmationTagId(tag.id); }} > Delete ))} {projectsTags.length === 0 && ( No tags found )} {deleteConfirmationTagId && ( setDeleteConfirmationTagId(undefined)} onConfirm={async () => { setDeleteConfirmationTagId(undefined); const updatedTags = projectsTags.filter( (tag) => tag.id !== deleteConfirmationTagId ); await nativeClient.user.updateProjectsTags.mutate({ tags: updatedTags, }); revalidator.revalidate(); }} /> )}
); }; const TagEdit = ({ projectsTags, tag, onComplete, }: { projectsTags: User["projectsTags"]; tag: User["projectsTags"][number]; onComplete: () => void; }) => { const revalidator = useRevalidator(); const isExisting = projectsTags.some(({ id }) => id === tag.id); return (
{ event.preventDefault(); const formData = new FormData(event.currentTarget); const label = ((formData.get("tag") as string) || "").trim(); if (tag.label === label || !label) { return; } let updatedTags = []; if (isExisting) { updatedTags = projectsTags.map((availableTag) => { if (availableTag.id === tag.id) { return { ...availableTag, label }; } return availableTag; }); } else { updatedTags = [...projectsTags, { id: tag.id, label }]; } await nativeClient.user.updateProjectsTags.mutate({ tags: updatedTags, }); revalidator.revalidate(); onComplete(); }} >
); }; export const TagsDialog = ({ projectId, projectsTags, projectTagsIds, isOpen, onOpenChange, }: { projectId: string; projectsTags: User["projectsTags"]; projectTagsIds: string[]; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; }) => { const [editingTag, setEditingTag] = useState< User["projectsTags"][number] | undefined >(); const revalidator = useRevalidator(); return ( {revalidator.state === "loading" && } } > Project tags {!editingTag && ( <> { setEditingTag(projectsTags.find((tag) => tag.id === tagId)); }} /> )} {editingTag && ( setEditingTag(undefined)} /> )} ); }; export const Tag = ({ index, tag, ...props }: { index: number; tag: User["projectsTags"][number] } & ComponentProps< typeof Button >) => { const [searchParams, setSearchParams] = useSearchParams(); const selectedTagsIds = searchParams.getAll("tag"); const color = colors[index] ?? theme.colors.backgroundNeutralDark; return (