diff --git a/nx/blocks/form/editor.js b/nx/blocks/form/editor.js
index a56d8c1ee..5e7b85509 100644
--- a/nx/blocks/form/editor.js
+++ b/nx/blocks/form/editor.js
@@ -3,7 +3,10 @@ import { LitElement, html, nothing } from 'da-lit';
import { createEngine } from '../../deps/da-sc-sdk/dist/index.js';
import { loadFormContext } from './utils/context.js';
import { attachPersistence } from './utils/persistence.js';
-import { loadStyle, hashChange } from '../../../nx2/utils/utils.js';
+import { createAssetSources } from './utils/assets.js';
+import {
+ ensurePreviewLogin, getLivePreviewUrl, loadStyle, hashChange,
+} from '../../../nx2/utils/utils.js';
import './views/editor.js';
import './views/sidebar.js';
@@ -19,16 +22,17 @@ const EL_NAME = 'nx-form';
// Derive the legacy `details` shape the form utils expect from the EW-supplied
// `ctx`. `ctx.path` is the full `org/site/path` locator; the DA source is the
-// `.html` document at that path.
+// `.html` document at that path. `parent` and `name` match the upload context Canvas uses.
function detailsFromCtx(ctx) {
if (!ctx?.org || !ctx?.repo || !ctx?.path) return null;
const raw = `/${String(ctx.path).replace(/^\/+/, '')}`;
const fullpath = raw.toLowerCase().endsWith('.html') ? raw : `${raw}.html`;
- const name = fullpath.slice(fullpath.lastIndexOf('/') + 1, -'.html'.length);
+ const separator = fullpath.lastIndexOf('/');
return {
owner: ctx.org,
repo: ctx.repo,
- name,
+ parent: fullpath.slice(0, separator),
+ name: fullpath.slice(separator + 1, -'.html'.length),
fullpath,
sourceUrl: fullpath,
};
@@ -41,13 +45,26 @@ function ctxFromHashState(state) {
return { org: state.org, repo: state.site, path: state.fullpath.slice(1) };
}
+async function loadAemRepoConfig({ owner, repo }) {
+ try {
+ const { getRepositoryConfig } = await import('../../../nx2/utils/aem-assets/repository-config.js');
+ return { repoConfig: await getRepositoryConfig(owner, repo) };
+ } catch (error) {
+ return { error };
+ }
+}
+
class Form extends LitElement {
static properties = {
ctx: { attribute: false },
+
_context: { state: true },
_state: { state: true },
_nav: { state: true },
_pendingSchemaId: { state: true },
+
+ _assetSources: { state: true },
+ _previewOrigin: { state: true },
};
// Reactive properties (declared in static properties) must NOT have class-
@@ -119,7 +136,10 @@ class Form extends LitElement {
this._state = this._editor.getState();
// Attach AFTER load so the loaded document is the persistence's baseline —
// mutations after this point trigger saves; the load itself does not.
- this._persistence = attachPersistence(this._editor, { path: this._details?.fullpath });
+ this._persistence = attachPersistence(this._editor, {
+ path: this._details?.fullpath,
+ schema,
+ });
this._nav = { pointer: '/data', origin: null, seq: 0 };
}
@@ -146,6 +166,10 @@ class Form extends LitElement {
this._editor = null;
this._persistence?.detach();
this._persistence = null;
+
+ this._assetSources = undefined;
+ this._previewOrigin = undefined;
+
this._details = detailsFromCtx(this.ctx);
if (!this._details) {
@@ -163,6 +187,34 @@ class Form extends LitElement {
if (context.status === 'ready') {
this._start({ schema: context.schema, json: context.json });
}
+
+ if (context.status === 'ready' || context.status === 'select-schema') {
+ await this._loadAssetContext(version);
+ }
+ }
+
+ // File sources and previews load independently, so a slow preview login never delays sources.
+ async _loadAssetContext(version) {
+ const { owner, repo } = this._details;
+ const isCurrent = () => version === this._loadVersion;
+
+ ensurePreviewLogin({ org: owner, repo }).then(() => {
+ if (isCurrent()) {
+ this._previewOrigin = getLivePreviewUrl({ org: owner, repo });
+ }
+ });
+
+ const { repoConfig, error } = await loadAemRepoConfig({ owner, repo });
+ if (!isCurrent()) {
+ return;
+ }
+
+ if (error) {
+ // eslint-disable-next-line no-console
+ console.warn('AEM Assets configuration unavailable; offering Upload only.', error);
+ }
+
+ this._assetSources = createAssetSources({ details: this._details, repoConfig, isCurrent });
}
_onPendingSchemaChange(e) {
@@ -359,6 +411,8 @@ class Form extends LitElement {
.state=${this._state}
.nav=${this._nav}
.onSelect=${this._onSelect}
+ .assetSources=${this._assetSources}
+ .previewOrigin=${this._previewOrigin}
>
diff --git a/nx/blocks/form/fields/asset.css b/nx/blocks/form/fields/asset.css
new file mode 100644
index 000000000..cea25581f
--- /dev/null
+++ b/nx/blocks/form/fields/asset.css
@@ -0,0 +1,129 @@
+:host {
+ --asset-preview-height: 194px;
+ --asset-row-height: 56px;
+ --asset-transparency-grid-size: 8px;
+ --asset-preview-fade: 130ms;
+ --asset-menu-icon-size: 16px;
+
+ display: block;
+ font-family: var(--s2-font-family, adobe-clean, "Source Sans Pro", system-ui, sans-serif);
+}
+
+/* Fixed rows in every state, so selecting, replacing or removing never shifts the page. */
+.asset {
+ display: grid;
+ grid-template-rows: var(--asset-preview-height) var(--asset-row-height);
+ overflow: hidden;
+ border: 1px solid var(--s2-gray-300);
+ border-radius: var(--s2-corner-radius-500);
+ background: var(--s2-gray-25);
+}
+
+.has-error .asset {
+ border-color: var(--s2-red-900);
+}
+
+.asset-preview {
+ display: grid;
+ grid-template: minmax(0, 1fr) / minmax(0, 1fr);
+ place-items: center;
+ overflow: hidden;
+ background: var(--s2-gray-100);
+}
+
+/* The image covers the file type badge once it has loaded. */
+.asset-preview > * {
+ grid-area: 1 / 1;
+}
+
+.asset-file-type {
+ padding: var(--s2-spacing-75) var(--s2-spacing-200);
+ border-radius: var(--s2-corner-radius-200);
+ background: var(--s2-gray-25);
+ color: var(--s2-gray-800);
+ font-size: var(--s2-body-size-s);
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.asset-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: 0;
+ transition: opacity var(--asset-preview-fade) ease-out;
+ background-color: var(--s2-gray-25);
+ background-image: conic-gradient(
+ var(--s2-gray-200) 90deg, transparent 90deg 180deg,
+ var(--s2-gray-200) 180deg 270deg, transparent 270deg
+ );
+ background-size: var(--asset-transparency-grid-size) var(--asset-transparency-grid-size);
+}
+
+.asset-preview img.is-loaded {
+ opacity: 1;
+}
+
+.asset-row {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: var(--s2-spacing-200);
+ min-width: 0;
+ padding: 0 var(--s2-spacing-200) 0 var(--s2-spacing-300);
+ border-top: 1px solid var(--s2-gray-200);
+}
+
+.asset-placeholder,
+.asset-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ font-size: var(--s2-body-size-s);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.asset-name {
+ display: flex;
+ color: var(--s2-gray-900);
+ font-weight: 600;
+}
+
+.asset-name-stem {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.asset-name-extension {
+ flex: none;
+}
+
+.asset-placeholder {
+ color: var(--s2-gray-700);
+}
+
+.asset-actions {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: var(--s2-spacing-100);
+}
+
+.asset-source-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s2-spacing-75);
+}
+
+.asset-menu-chevron {
+ width: var(--asset-menu-icon-size);
+ height: var(--asset-menu-icon-size);
+}
+
+nx-dialog p {
+ margin: 0;
+}
diff --git a/nx/blocks/form/fields/asset.js b/nx/blocks/form/fields/asset.js
new file mode 100644
index 000000000..046c0920b
--- /dev/null
+++ b/nx/blocks/form/fields/asset.js
@@ -0,0 +1,316 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { loadStyle } from '../../../../nx2/utils/utils.js';
+import '../../../../nx2/blocks/shared/menu/menu.js';
+import '../../../../nx2/blocks/shared/dialog/dialog.js';
+import { icon } from '../icons.js';
+import {
+ CANCELLED, describeAsset, extensionOf, isAssetHref, previewHrefFor,
+} from '../utils/assets.js';
+import defaults from './defaults.js';
+
+const [style, formStyle] = await Promise.all([
+ loadStyle(import.meta.url),
+ loadStyle(new URL('../../../../nx2/styles/form.css', import.meta.url).href),
+]);
+
+const LABELS = {
+ select: 'Select',
+ replace: 'Replace',
+ remove: 'Remove',
+ cancel: 'Cancel',
+ adding: (kind) => `Adding ${kind}`,
+ empty: (kind) => `No ${kind} selected`,
+ removeTitle: (kind) => `Remove ${kind}?`,
+ removeBody: (kind) => `The ${kind} will be removed from this field.`,
+};
+
+const MESSAGES = {
+ failed: 'The file could not be added.',
+ noSource: 'No source is available for this field.',
+ unusableResult: 'The selected file did not return a usable URL.',
+};
+
+const MENU_PLACEMENT = 'below-end';
+const SELECTORS = {
+ fileInput: '.file-input',
+ removeButton: '.asset-remove',
+ sourceTrigger: '.asset-source-trigger',
+};
+class FormAsset extends LitElement {
+ static properties = {
+ value: { type: String },
+ label: { type: String },
+ description: { type: String },
+ error: { type: String },
+ required: { type: Boolean },
+ disabled: { type: Boolean, reflect: true },
+
+ sources: { attribute: false },
+ previewOrigin: { type: String },
+
+ _pending: { state: true },
+ _selectionError: { state: true },
+ _loadedPreviewSrc: { state: true },
+ _confirmingRemove: { state: true },
+ };
+
+ // Results of an older request are ignored once a newer one starts or the value changes.
+ _activeRequestId = 0;
+
+ _selection;
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [defaults, formStyle, style];
+ }
+
+ disconnectedCallback() {
+ this._forgetSelection();
+ this._confirmingRemove = false;
+ super.disconnectedCallback();
+ }
+
+ willUpdate(changed) {
+ if (changed.has('value') && this.value !== this._selection?.href) this._forgetSelection();
+ this._asset = describeAsset({
+ href: this.value,
+ name: this._selection?.name,
+ type: this._selection?.type,
+ });
+ }
+
+ get _kindLabel() {
+ return this.value && this._asset.isImage ? 'image' : 'file';
+ }
+
+ get _availableSources() {
+ return this.sources ?? [];
+ }
+
+ get _previewSrc() {
+ if (!this._asset.isImage) return undefined;
+ return previewHrefFor({ href: this.value, previewOrigin: this.previewOrigin });
+ }
+
+ get _canChange() {
+ return !this.disabled && !this._pending;
+ }
+
+ get _canRemove() {
+ return this._canChange && !!this.value;
+ }
+
+ _forgetSelection() {
+ this._activeRequestId += 1;
+ this._selection = undefined;
+ this._selectionError = undefined;
+ this._pending = false;
+ }
+
+ // Native `change` events are not composed, so the host emits its own.
+ _emitChange() {
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
+ }
+
+ async _focus(selector) {
+ await this.updateComplete;
+ if (this.isConnected) this.shadowRoot.querySelector(selector)?.focus();
+ }
+
+ _pickFile(types) {
+ const input = this.shadowRoot.querySelector(SELECTORS.fileInput);
+ input.accept = types.join(',');
+ input.value = '';
+ return new Promise((resolve) => {
+ const listening = new AbortController();
+ const finish = () => {
+ listening.abort();
+ resolve(input.files?.[0]);
+ };
+ input.addEventListener('change', finish, { signal: listening.signal });
+ input.addEventListener('cancel', finish, { signal: listening.signal });
+ input.click();
+ });
+ }
+
+ async _runSource(source) {
+ try {
+ if (!source.fileTypes) return await source.select({});
+ const file = await this._pickFile(source.fileTypes);
+ return file ? await source.select({ file }) : CANCELLED;
+ } catch (error) {
+ return { error: error?.message || MESSAGES.failed };
+ }
+ }
+
+ _applyResult(result) {
+ if (result.cancelled) return;
+ if (result.error || !isAssetHref(result.href)) {
+ this._selectionError = result.error || MESSAGES.unusableResult;
+ return;
+ }
+ this._selection = { href: result.href, name: result.name, type: result.type };
+ this.value = result.href;
+ this._emitChange();
+ }
+
+ async _selectFrom(source) {
+ if (!this._canChange || !source) {
+ return;
+ }
+
+ this._activeRequestId += 1;
+ const requestId = this._activeRequestId;
+ this._pending = true;
+ this._selectionError = undefined;
+
+ const result = await this._runSource(source);
+
+ // A newer selection or a cleared field superseded this request.
+ if (requestId !== this._activeRequestId) {
+ return;
+ }
+
+ this._pending = false;
+ this._applyResult(result);
+ this._focus(SELECTORS.sourceTrigger);
+ }
+
+ _onMenuSelect({ detail }) {
+ this._selectFrom(this._availableSources.find(({ id }) => id === detail.id));
+ }
+
+ _openRemoveDialog() {
+ if (this._canRemove) this._confirmingRemove = true;
+ }
+
+ _closeRemoveDialog() {
+ this.shadowRoot.querySelector('nx-dialog')?.close();
+ }
+
+ _onRemoveDialogClose() {
+ this._confirmingRemove = false;
+ this._focus(this.value ? SELECTORS.removeButton : SELECTORS.sourceTrigger);
+ }
+
+ // Clearing the field never deletes the file at its source.
+ _confirmRemove() {
+ if (this._canRemove) {
+ this._forgetSelection();
+ this.value = undefined;
+ this._emitChange();
+ }
+ this._closeRemoveDialog();
+ }
+
+ _onPreviewLoad(event) {
+ this._loadedPreviewSrc = event.currentTarget.getAttribute('src');
+ }
+
+ _renderSourceTrigger() {
+ const text = this.value ? LABELS.replace : LABELS.select;
+ const className = `asset-source-trigger ${this.value ? 'nx-form-btn-secondary' : 'nx-form-btn-primary'}`;
+ const sources = this._availableSources;
+ if (sources.length > 1) {
+ const items = sources.map(({ id, label }) => ({ id, label }));
+ return html`
+
+
+
+ `;
+ }
+ const [source] = sources;
+ return html``;
+ }
+
+ _renderActions() {
+ if (this._pending) {
+ return html``;
+ }
+ return html`
+ ${this._renderSourceTrigger()}
+ ${this.value ? html`` : nothing}
+ `;
+ }
+
+ _renderPreview() {
+ const previewSrc = this._previewSrc;
+ return html`
+
+ ${this.value
+ ? this._renderName(name)
+ : html`
${LABELS.empty(this._kindLabel)}`}
+
${this._renderActions()}
+
+ `;
+ }
+
+ _renderRemoveDialog() {
+ if (!this._confirmingRemove) return nothing;
+ return html`
+