`,
+ );
+ return htmlString;
+}
+
+const SKIP_BLOCK_CLASSES = new Set(['default-content-wrapper', 'metadata', 'block-marker']);
+
+export function parseSections(htmlText) {
+ const doc = new DOMParser().parseFromString(htmlText, 'text/html');
+ const container = doc.querySelector('main') ?? doc.body;
+ let flatIndex = 0;
+ return Array.from(container.querySelectorAll(':scope > div'), (section, sectionIndex) => {
+ const blocks = [];
+ Array.from(section.querySelectorAll(':scope > div[class]')).forEach((el) => {
+ const name = el.classList[0];
+ if (!name || SKIP_BLOCK_CLASSES.has(name)) return;
+ const rawProseIndex = el.getAttribute('data-block-index');
+ const proseIndex = rawProseIndex != null ? Number(rawProseIndex) : undefined;
+ const innerText = el.textContent?.trim() ?? '';
+ blocks.push({ name, blockIndex: flatIndex, proseIndex, innerText });
+ flatIndex += 1;
+ });
+ return { sectionIndex, blocks };
+ });
+}
+
+// State observable — replays last value on subscribe. See docs/canvas-events.md.
+export const editorHtmlChange = (() => {
+ const listeners = new Set();
+ let currentHtml = '';
+ return {
+ emit(html) {
+ currentHtml = html;
+ listeners.forEach((fn) => fn(html));
+ },
+ subscribe(fn) {
+ listeners.add(fn);
+ if (currentHtml) fn(currentHtml);
+ return () => listeners.delete(fn);
+ },
+ };
+})();
+
+// Event observable — no replay on subscribe. See docs/canvas-events.md.
+// emit() enriches the detail with blockName/proseIndex/innerText from the last parsed HTML.
+export const editorSelectChange = (() => {
+ const listeners = new Set();
+ let blockMeta = new Map();
+
+ editorHtmlChange.subscribe((html) => {
+ if (!html.trim()) {
+ blockMeta = new Map();
+ return;
+ }
+ const next = new Map();
+ for (const { blocks } of parseSections(html)) {
+ for (const { name, blockIndex, proseIndex, innerText } of blocks) {
+ next.set(blockIndex, { name, proseIndex, innerText });
+ }
+ }
+ blockMeta = next;
+ });
+
+ return {
+ emit(detail) {
+ const meta = blockMeta.get(detail.blockIndex);
+ const { name: blockName, proseIndex, innerText } = meta || {};
+ const enriched = meta
+ ? { ...detail, blockName, proseIndex, innerText }
+ : detail;
+ listeners.forEach((fn) => fn(enriched));
+ },
+ subscribe(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ },
+ };
+})();
+
+export function updateDocument(ctx) {
+ if (ctx.suppressRerender) return undefined;
+ const body = getInstrumentedHTML(ctx.view);
+ ctx.port.postMessage({ type: 'set-body', body });
+ return body;
+}
+
+export function updateCursors(ctx) {
+ const cursors = extractCursors(ctx.view);
+ ctx.port.postMessage({ type: 'set-cursors', cursors });
+}
+
+// --- preview.js ---
+
+export function getPreviewOrigin(org, repo) {
+ const hostname = window?.location?.hostname ?? '';
+ const domain = hostname.endsWith('aem.page') || hostname.endsWith('localhost')
+ ? 'stage-preview.da.live'
+ : 'preview.da.live';
+ return `https://main--${repo}--${org}.${domain}`;
+}
+
+export async function fetchWysiwygCookie({ org, repo, token }) {
+ if (!org || !repo || !token) {
+ throw new Error('fetchWysiwygCookie: org, repo, and token required');
+ }
+ const previewUrl = `${getPreviewOrigin(org, repo)}/gimme_cookie`;
+ const contentUrl = `${DA_CONTENT}/${org}/${repo}/.gimme_cookie`;
+
+ const previewResp = await daFetch(previewUrl, { method: 'GET', credentials: 'include', headers: { Authorization: `Bearer ${token}` } });
+ if (!previewResp.ok) {
+ throw new Error(`gimme_cookie preview failed: status ${previewResp.status}`);
+ }
+
+ try {
+ const contentResp = await fetch(contentUrl, { method: 'GET', credentials: 'include' });
+ if (!contentResp.ok) {
+ // eslint-disable-next-line no-console
+ console.warn('[canvas:wysiwyg] content gimme_cookie non-ok (non-fatal)', contentResp.status);
+ }
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[canvas:wysiwyg] content gimme_cookie failed (non-fatal)', e?.message);
+ }
+}
diff --git a/blocks/canvas/editor-utils/extensions-bridge.js b/blocks/canvas/editor-utils/extensions-bridge.js
new file mode 100644
index 00000000..241701a3
--- /dev/null
+++ b/blocks/canvas/editor-utils/extensions-bridge.js
@@ -0,0 +1,20 @@
+/* eslint-disable import/no-unresolved -- importmap */
+import { Plugin } from 'da-y-wrapper';
+
+const bridge = { view: null };
+
+export function getExtensionsBridge() {
+ return bridge;
+}
+
+export function createExtensionsBridgePlugin() {
+ return new Plugin({
+ view(editorView) {
+ bridge.view = editorView;
+ return {
+ update(view) { bridge.view = view; },
+ destroy() { bridge.view = null; },
+ };
+ },
+ });
+}
diff --git a/blocks/canvas/editor-utils/prose-diff.js b/blocks/canvas/editor-utils/prose-diff.js
new file mode 100644
index 00000000..a643ef6c
--- /dev/null
+++ b/blocks/canvas/editor-utils/prose-diff.js
@@ -0,0 +1,172 @@
+import { Plugin } from 'da-y-wrapper';
+
+export function findChangedNodes(oldDoc, newDoc) {
+ const changes = [];
+
+ function traverse(oldNode, newNode, pos) {
+ if (oldNode === newNode) return;
+
+ if (!oldNode || !newNode || oldNode.type !== newNode.type) {
+ changes.push({
+ type: 'replaced',
+ pos,
+ oldNode,
+ newNode,
+ });
+ return;
+ }
+
+ if (oldNode.isText && newNode.isText) {
+ if (oldNode.text !== newNode.text) {
+ changes.push({
+ type: 'text',
+ pos,
+ oldText: oldNode.text,
+ newText: newNode.text,
+ });
+ return;
+ }
+ }
+
+ if (oldNode.isText || newNode.isText) {
+ const oldMarks = oldNode.marks || [];
+ const newMarks = newNode.marks || [];
+ if (oldMarks.length !== newMarks.length
+ || !oldMarks.every((m, i) => m.eq(newMarks[i]))) {
+ changes.push({
+ type: 'marks',
+ pos,
+ oldMarks,
+ newMarks,
+ });
+ }
+ }
+
+ if (!oldNode.sameMarkup(newNode)) {
+ changes.push({
+ type: 'attrs',
+ pos,
+ oldAttrs: oldNode.attrs,
+ newAttrs: newNode.attrs,
+ });
+ }
+
+ const oldSize = oldNode.childCount;
+ const newSize = newNode.childCount;
+ const minSize = Math.min(oldSize, newSize);
+
+ let oldPos = pos + 1;
+ let newPos = pos + 1;
+
+ for (let i = 0; i < minSize; i += 1) {
+ const oldChild = oldNode.child(i);
+ const newChild = newNode.child(i);
+ traverse(oldChild, newChild, oldPos);
+ oldPos += oldChild.nodeSize;
+ newPos += newChild.nodeSize;
+ }
+
+ if (newSize > oldSize) {
+ for (let i = oldSize; i < newSize; i += 1) {
+ const newChild = newNode.child(i);
+ changes.push({
+ type: 'added',
+ pos: newPos,
+ node: newChild,
+ });
+ newPos += newChild.nodeSize;
+ }
+ }
+
+ if (oldSize > newSize) {
+ for (let i = newSize; i < oldSize; i += 1) {
+ const oldChild = oldNode.child(i);
+ changes.push({
+ type: 'deleted',
+ pos: oldPos,
+ node: oldChild,
+ });
+ oldPos += oldChild.nodeSize;
+ }
+ }
+ }
+
+ traverse(oldDoc, newDoc, 0);
+ return changes;
+}
+
+export const EDITABLE_TYPES = ['heading', 'paragraph', 'ordered_list', 'bullet_list'];
+
+export function findCommonEditableAncestor(view, changes, prevState) {
+ if (changes.length === 0) return null;
+
+ const editableAncestors = [];
+
+ for (const change of changes) {
+ const isDeletedNode = change.type === 'deleted';
+ try {
+ const doc = isDeletedNode ? prevState.doc : view.state.doc;
+ const $pos = doc.resolve(change.pos);
+ let editableAncestor = null;
+
+ for (let { depth } = $pos; depth > 0; depth -= 1) {
+ const node = $pos.node(depth);
+ if (EDITABLE_TYPES.includes(node.type.name)) {
+ editableAncestor = {
+ node,
+ pos: $pos.before(depth),
+ };
+ }
+ }
+
+ if (editableAncestor) {
+ editableAncestors.push(editableAncestor);
+ } else if (!isDeletedNode) {
+ return null;
+ }
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('Could not resolve position for change:', e);
+ return null;
+ }
+ }
+
+ if (editableAncestors.length === 0) return null;
+
+ const firstPos = editableAncestors[0].pos;
+ const allSameAncestor = editableAncestors.every((ancestor) => ancestor.pos === firstPos);
+
+ return allSameAncestor ? editableAncestors[0] : null;
+}
+
+export function createTrackingPlugin(rerenderPage, updateCursors, getEditor, onSelectionChange) {
+ return new Plugin({
+ view() {
+ return {
+ update(view, prevState) {
+ const docChanged = view.state.doc !== prevState.doc;
+
+ if (docChanged) {
+ const changes = findChangedNodes(prevState.doc, view.state.doc);
+
+ if (changes.length > 0) {
+ const commonEditable = findCommonEditableAncestor(view, changes, prevState);
+
+ if (commonEditable) {
+ getEditor?.({ cursorOffset: commonEditable.pos + 1 });
+ } else {
+ rerenderPage?.();
+ }
+ }
+ }
+
+ updateCursors?.();
+
+ if (view.state.selection !== prevState.selection) {
+ onSelectionChange?.(view);
+ }
+ },
+ };
+ },
+ });
+}
diff --git a/blocks/canvas/editor-utils/selection-toolbar.js b/blocks/canvas/editor-utils/selection-toolbar.js
new file mode 100644
index 00000000..2638ade9
--- /dev/null
+++ b/blocks/canvas/editor-utils/selection-toolbar.js
@@ -0,0 +1,94 @@
+/* eslint-disable import/no-unresolved -- importmap */
+import { Plugin, PluginKey, NodeSelection } from 'da-y-wrapper';
+
+const NON_TEXT_NODES = new Set(['table', 'image']);
+
+/** Set on transactions that mirror WYSIWYG iframe text selection into ProseMirror. */
+export const NX_QUICK_EDIT_IFRAME_SELECTION_META = 'nxQuickEditIframeSelection';
+
+/** Clears iframe-origin flag when the iframe reports a caret (no range). */
+export const NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META = 'nxClearQuickEditIframeSelectionOrigin';
+
+const selectionToolbarOriginKey = new PluginKey('nxSelectionToolbarOrigin');
+
+function getSelectionOriginFromIframe(state) {
+ return selectionToolbarOriginKey.getState(state)?.fromIframe ?? false;
+}
+
+export const TOOLBAR_PADDING_GAP = 64;
+
+let toolbar;
+let componentLoaded;
+
+export function getSelectionToolbar() {
+ if (toolbar) return toolbar;
+ componentLoaded ??= import('../ew-selection-toolbar/ew-selection-toolbar.js');
+ toolbar = document.createElement('ew-selection-toolbar');
+ document.body.append(toolbar);
+ return toolbar;
+}
+
+export function hideSelectionToolbar() {
+ toolbar?.hide?.();
+}
+
+function isNonTextSelection({ selection }) {
+ return selection instanceof NodeSelection
+ && NON_TEXT_NODES.has(selection.node.type.name);
+}
+
+function syncToolbar(view) {
+ if (!view) return;
+ const tb = getSelectionToolbar();
+ if (tb.linkDialogOpen) return;
+ if (view.state.selection.empty || isNonTextSelection(view.state)) {
+ hideSelectionToolbar();
+ return;
+ }
+ const start = view.coordsAtPos(view.state.selection.from);
+ tb.view = view;
+ tb.show({ x: start.left, y: start.top - TOOLBAR_PADDING_GAP });
+}
+
+export function createSelectionToolbarPlugin() {
+ return new Plugin({
+ key: selectionToolbarOriginKey,
+ state: {
+ init: () => ({ fromIframe: false }),
+ apply(tr, prev) {
+ if (tr.getMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META)) return { fromIframe: true };
+ if (tr.getMeta(NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META)) {
+ return { fromIframe: false };
+ }
+ if (tr.selectionSet) return { fromIframe: false };
+ return prev;
+ },
+ },
+ view() {
+ let scrollEl;
+ const tb = getSelectionToolbar();
+ const onScroll = () => {
+ if (tb.view && getSelectionOriginFromIframe(tb.view.state)) return;
+ syncToolbar(tb.view);
+ };
+
+ return {
+ update(view) {
+ if (!scrollEl) {
+ scrollEl = view.dom.closest('.ew-editor-doc');
+ scrollEl?.addEventListener('scroll', onScroll, { passive: true });
+ }
+ const header = document.querySelector('ew-canvas-header');
+ const ev = header?.editorView;
+ if (ev !== 'content' && ev !== 'split') return;
+ if (getSelectionOriginFromIframe(view.state)) return;
+ syncToolbar(view);
+ },
+ destroy() {
+ scrollEl?.removeEventListener('scroll', onScroll);
+ hideSelectionToolbar();
+ },
+ };
+ },
+ });
+}
diff --git a/blocks/canvas/ew-canvas-header/ew-canvas-header.css b/blocks/canvas/ew-canvas-header/ew-canvas-header.css
new file mode 100644
index 00000000..a3570e81
--- /dev/null
+++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.css
@@ -0,0 +1,164 @@
+:host {
+ display: block;
+ box-sizing: border-box;
+ font-family: var(
+ --s2-font-family,
+ adobe-clean,
+ "Source Sans Pro",
+ "Trebuchet MS",
+ sans-serif
+ );
+ font-size: var(--s2-body-size-s, 0.875rem);
+ color: var(--s2-gray-800);
+}
+
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ min-width: 24px;
+ min-height: 24px;
+ padding: 0 4px;
+ margin: 0;
+ border: none;
+ border-radius: 8px;
+ font: inherit;
+ font-size: var(--s2-body-size-xs, 0.75rem);
+ color: var(--s2-gray-800);
+ background: transparent;
+ cursor: pointer;
+}
+
+.icon-btn img {
+ display: block;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+}
+
+.icon-btn:focus-visible {
+ outline: 2px solid var(--s2-blue-800);
+ outline-offset: 2px;
+}
+
+.icon-btn:disabled {
+ color: var(--s2-gray-400);
+ cursor: not-allowed;
+}
+
+.icon-btn:hover:not(:disabled) {
+ background-color: var(--s2-gray-75);
+}
+
+.icon-btn:disabled img {
+ opacity: 0.45;
+}
+
+.segmented {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--s2-spacing-50);
+ border-radius: calc(var(--s2-corner-radius-400) + var(--s2-spacing-50));
+ gap: var(--s2-spacing-50);
+ background-color: var(--s2-gray-100);
+}
+
+.segment {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ margin: 0;
+ height: 24px;
+ padding: 0 9px;
+ border: none;
+ border-radius: var(--s2-corner-radius-400);
+ font: inherit;
+ font-size: var(--s2-body-size-xs);
+ line-height: 16px;
+ font-weight: var(--s2-component-s-medium-font-weight);
+ color: var(--s2-gray-700);
+ background: transparent;
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ color 0.12s ease,
+ background-color 0.12s ease,
+ box-shadow 0.12s ease;
+
+ &:focus {
+ outline: none;
+ }
+
+ &:hover:not(.is-selected) {
+ color: var(--s2-gray-900);
+ background-color: var(--s2-gray-200);
+ }
+
+ &.is-selected {
+ color: var(--s2-gray-800);
+ background-color: var(--s2-gray-25);
+
+ &:focus {
+ box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
+ }
+ }
+
+ &.segment-icon {
+ padding: 0 6px;
+ min-width: 28px;
+ }
+
+ &.segment-icon img {
+ display: block;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ }
+}
+
+.bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ box-sizing: border-box;
+ height: var(--ew-canvas-header-height);
+ padding: 0 12px;
+ background-color: light-dark(#fff, var(--s2-gray-25));
+ border-bottom: 1px solid var(--s2-gray-200);
+}
+
+.group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.group-center {
+ flex: 1;
+ justify-content: center;
+ min-width: 0;
+}
+
+@media (width < 480px) {
+ .bar {
+ flex-wrap: wrap;
+ justify-content: center;
+ padding-block: 8px;
+ row-gap: 8px;
+ }
+
+ .group-center {
+ order: 0;
+ flex: 1 1 100%;
+ justify-content: center;
+ }
+
+ .group-start,
+ .group-end {
+ order: 1;
+ }
+}
diff --git a/blocks/canvas/ew-canvas-header/ew-canvas-header.js b/blocks/canvas/ew-canvas-header/ew-canvas-header.js
new file mode 100644
index 00000000..7f3a532c
--- /dev/null
+++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.js
@@ -0,0 +1,135 @@
+import { LitElement, html } from 'da-lit';
+
+import { getNx } from '../../../scripts/utils.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+const ICONS = {
+ undo: '/img/icons/s2-icon-undo-20-n.svg',
+ redo: '/img/icons/s2-icon-redo-20-n.svg',
+ splitLeft: '/blocks/canvas/img/s2-icon-splitleft-20-n.svg',
+ splitRight: '/blocks/canvas/img/s2-icon-splitright-20-n.svg',
+ gridCompare: '/blocks/canvas/img/s2-icon-gridcompare-20-n.svg',
+};
+
+const EDITOR_VIEWS = /** @type {const} */ (['layout', 'content', 'split']);
+
+class EWCanvasHeader extends LitElement {
+ static properties = {
+ /** `'layout'` / `'content'` = single pane; `'split'` = doc + WYSIWYG side by side */
+ editorView: { type: String, reflect: true },
+ undoAvailable: { type: Boolean },
+ redoAvailable: { type: Boolean },
+ };
+
+ constructor() {
+ super();
+ this.editorView = 'layout';
+ this.undoAvailable = false;
+ this.redoAvailable = false;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ }
+
+ _openPanel(position) {
+ this.dispatchEvent(
+ new CustomEvent('nx-canvas-open-panel', {
+ bubbles: true,
+ composed: true,
+ detail: { position },
+ }),
+ );
+ }
+
+ _undo() {
+ this.dispatchEvent(
+ new CustomEvent('nx-canvas-undo', { bubbles: true, composed: true }),
+ );
+ }
+
+ _redo() {
+ this.dispatchEvent(
+ new CustomEvent('nx-canvas-redo', { bubbles: true, composed: true }),
+ );
+ }
+
+ _setEditorView(view) {
+ if (!EDITOR_VIEWS.includes(view) || view === this.editorView) return;
+ this.editorView = view;
+ this.dispatchEvent(
+ new CustomEvent('nx-canvas-editor-view', {
+ bubbles: true,
+ composed: true,
+ detail: { view },
+ }),
+ );
+ }
+
+ _renderIcon(name) {
+ return html`

`;
+ }
+
+ render() {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+}
+
+customElements.define('ew-canvas-header', EWCanvasHeader);
diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.css b/blocks/canvas/ew-editor-doc/ew-editor-doc.css
new file mode 100644
index 00000000..8eebd644
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.css
@@ -0,0 +1,489 @@
+/* stylelint-disable selector-class-pattern -- ProseMirror and Yjs use their own class names */
+
+.ew-editor-doc {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden auto;
+}
+
+.ew-editor-doc .ew-editor-doc-mount {
+ flex: 1;
+ min-height: 0;
+ max-width: 800px;
+ margin: auto;
+ width: 100%;
+}
+
+.ew-editor-doc .da-prose-mirror {
+ flex: 1;
+ min-height: 0;
+ position: relative;
+}
+
+.ew-editor-doc .ProseMirror {
+ min-height: 200px;
+ background: light-dark(#fff, var(--s2-gray-75, #2c2c2c));
+ color: light-dark(#222, var(--s2-gray-900, #f5f5f5));
+ position: relative;
+ padding: 1rem;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ box-sizing: border-box;
+}
+
+.ew-editor-doc .ProseMirror-focused {
+ outline: none;
+}
+
+.ew-editor-doc .da-slash-hint {
+ position: absolute;
+ pointer-events: none;
+ color: var(--s2-gray-500, rgb(143 143 143));
+ white-space: nowrap;
+ user-select: none;
+}
+
+.ew-editor-doc .ProseMirror > *:first-child {
+ margin-top: 0;
+}
+
+.ew-editor-doc .ProseMirror img {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Table chrome matches da.live da-editor.css “COPY FROM TABLES”: wrapper holds the
+ outer border; table uses border-style hidden so cell borders do not stack on it. */
+.ew-editor-doc .ProseMirror table {
+ border-collapse: collapse;
+ table-layout: fixed;
+ width: 100% !important;
+ overflow: hidden;
+ border-style: hidden;
+}
+
+.ew-editor-doc .ProseMirror td,
+.ew-editor-doc .ProseMirror th {
+ border: 2px solid light-dark(#b1b1b1, var(--s2-gray-400, #6e6e6e));
+ vertical-align: top;
+ min-width: 100px;
+ padding: 8px;
+ box-sizing: border-box;
+ position: relative;
+}
+
+.ew-editor-doc .ProseMirror tr:first-child td,
+.ew-editor-doc .ProseMirror tr:first-child th {
+ background: light-dark(#f1f1f1, var(--s2-gray-200, #3d3d3d));
+ text-align: center;
+ font-weight: 700;
+}
+
+.ew-editor-doc .ProseMirror td.selectedCell,
+.ew-editor-doc .ProseMirror th.selectedCell {
+ background: light-dark(#e9f4ff, rgb(20 115 230 / 25%));
+ box-shadow: inset 0 2px 5px rgb(0 0 0 / 12%);
+}
+
+.ew-editor-doc .ProseMirror tr:first-child td.selectedCell,
+.ew-editor-doc .ProseMirror tr:first-child th.selectedCell {
+ background: light-dark(#d1e4f8, rgb(20 115 230 / 35%));
+}
+
+.ew-editor-doc .ProseMirror td > *:first-child,
+.ew-editor-doc .ProseMirror th > *:first-child {
+ margin-top: 0;
+}
+
+.ew-editor-doc .ProseMirror td > *:last-child,
+.ew-editor-doc .ProseMirror th > *:last-child {
+ margin-bottom: 0;
+}
+
+.ew-editor-doc p code,
+.ew-editor-doc pre {
+ background: light-dark(#e4ecfa, rgb(20 115 230 / 15%));
+ border: 1px solid light-dark(#becee9, var(--s2-gray-500, #5c5c5c));
+ padding: 0 4px;
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+.ew-editor-doc .ProseMirror pre {
+ white-space: pre-wrap;
+}
+
+.ew-editor-doc blockquote {
+ position: relative;
+ padding: 0 0.5rem 0 1.5rem;
+ margin: 0;
+}
+
+.ew-editor-doc blockquote::before {
+ position: absolute;
+ display: block;
+ content: '';
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 4px;
+ background: light-dark(#848484, var(--s2-gray-500, #8f8f8f));
+ border-radius: 2px;
+}
+
+.ew-editor-doc .ProseMirror-hideselection *::selection {
+ background: transparent;
+}
+
+.ew-editor-doc .ProseMirror-selectednode {
+ outline: 2px solid var(--s2-blue-800, #1473e6);
+}
+
+.ew-editor-doc .ProseMirror-gapcursor::after {
+ content: "";
+ display: block;
+ position: absolute;
+ top: -2px;
+ width: 20px;
+ border-top: 1px solid light-dark(#000, #fff);
+ animation: nx-pm-cursor-blink 1.1s steps(2, start) infinite;
+}
+
+@keyframes nx-pm-cursor-blink {
+ to {
+ visibility: hidden;
+ }
+}
+
+.ew-editor-doc .ProseMirror .tableWrapper {
+ overflow-x: auto;
+ border: 2px solid light-dark(#b1b1b1, var(--s2-gray-400, #6e6e6e));
+ border-radius: 6px;
+ margin: 2px 0;
+}
+
+.ew-editor-doc .ProseMirror .column-resize-handle {
+ position: absolute;
+ right: -2px;
+ top: 0;
+ bottom: 0;
+ width: 4px;
+ z-index: 20;
+ background-color: light-dark(#adf, rgb(20 115 230 / 35%));
+ pointer-events: none;
+}
+
+.ew-editor-doc .ProseMirror.resize-cursor {
+ cursor: ew-resize;
+ cursor: col-resize;
+}
+
+.ew-editor-doc .ProseMirror td > *:has(+ .column-resize-handle),
+.ew-editor-doc .ProseMirror th > *:has(+ .column-resize-handle) {
+ margin-bottom: 0;
+}
+
+.ew-editor-doc .ProseMirror-yjs-cursor {
+ position: relative;
+ margin-left: -1px;
+ margin-right: -1px;
+ border-left: 1px solid black;
+ border-right: 1px solid black;
+ border-color: orange;
+ word-break: normal;
+ pointer-events: none;
+}
+
+.ew-editor-doc .ProseMirror-yjs-cursor > div {
+ position: absolute;
+ top: -1.05em;
+ left: -1px;
+ font-size: 13px;
+ background-color: rgb(250 129 0);
+ border-radius: 2px;
+ padding: 0 3px;
+ white-space: nowrap;
+ color: white;
+}
+
+.ew-editor-doc-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: light-dark(#505050, var(--s2-gray-700, #cacaca));
+ font-size: 0.875rem;
+ padding: 1rem;
+ text-align: center;
+ height: 100%;
+ min-height: 200px;
+}
+
+.ew-editor-doc .ew-editor-doc-placeholder code {
+ background: light-dark(#e5e5e5, var(--s2-gray-200, #3d3d3d));
+ padding: 0.125rem 0.375rem;
+ border-radius: 2px;
+}
+
+.ew-editor-doc-error {
+ color: light-dark(#c00, #ff6b6b);
+ font-size: 0.875rem;
+ padding: 0.5rem;
+}
+
+/* Ported from da.live prose: table select handle + image focal point */
+
+.ew-editor-doc .table-select-handle {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ background-color: light-dark(#fff, var(--s2-gray-75, #2c2c2c));
+ background-image: url('https://da.live/img/icons/s2-icon-select-20-n.svg');
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 16px;
+ border: 1px solid light-dark(#ccc, var(--s2-gray-400, #6e6e6e));
+ border-radius: 4px;
+ z-index: 100;
+ cursor: pointer;
+ display: none;
+ align-items: center;
+ justify-content: center;
+}
+
+.ew-editor-doc .table-select-handle.is-visible {
+ display: flex;
+}
+
+.ew-editor-doc .table-select-handle:hover {
+ background-color: light-dark(#f0f7ff, rgb(20 115 230 / 20%));
+ border-color: var(--s2-blue-800, #1473e6);
+}
+
+.ew-editor-doc .focal-point-image-wrapper {
+ position: relative;
+ display: block;
+}
+
+.ew-editor-doc .focal-point-image-wrapper img {
+ display: block;
+ position: relative;
+}
+
+.ew-editor-doc .focal-point-icon {
+ position: absolute;
+ bottom: 4px;
+ left: 4px;
+ width: 32px;
+ height: 32px;
+ background: light-dark(rgb(255 255 255 / 90%), rgb(44 44 44 / 90%));
+ border: 1px solid light-dark(#ccc, var(--s2-gray-400, #6e6e6e));
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ z-index: 10;
+ pointer-events: auto;
+}
+
+.ew-editor-doc .focal-point-icon-active {
+ opacity: 1;
+}
+
+.ew-editor-doc .focal-point-icon:hover {
+ background: light-dark(#fff, var(--s2-gray-75, #3d3d3d));
+ border-color: var(--s2-blue-800, #1473e6);
+}
+
+.ew-editor-doc .focal-point-image-wrapper:hover .focal-point-icon:not(.focal-point-icon-active) {
+ opacity: 1;
+}
+
+.ew-editor-doc .focal-point-icon svg {
+ color: light-dark(#505050, var(--s2-gray-700, #cacaca));
+}
+
+.ew-editor-doc .focal-point-icon svg .fill {
+ fill: currentcolor;
+}
+
+.ew-editor-doc .focal-point-icon:hover svg {
+ color: var(--s2-blue-800, #1473e6);
+}
+
+/* Native focal point dialog (da.live used da-dialog) */
+
+.nx-focal-point-dialog {
+ border: none;
+ border-radius: 8px;
+ padding: 0;
+ max-width: min(720px, 96vw);
+ background: light-dark(#fff, var(--s2-gray-75, #2c2c2c));
+ color: light-dark(#222, var(--s2-gray-900, #f5f5f5));
+ box-shadow: 0 8px 32px rgb(0 0 0 / 24%);
+}
+
+.nx-focal-point-dialog::backdrop {
+ background: rgb(0 0 0 / 45%);
+}
+
+.nx-focal-point-dialog__title {
+ margin: 0;
+ padding: 1rem 1.25rem;
+ font-size: 1.125rem;
+ font-weight: 600;
+ border-bottom: 1px solid light-dark(#e0e0e0, var(--s2-gray-400, #5c5c5c));
+}
+
+.nx-focal-point-dialog__footer {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ padding: 1rem 1.25rem;
+ border-top: 1px solid light-dark(#e0e0e0, var(--s2-gray-400, #5c5c5c));
+}
+
+.nx-focal-point-dialog__btn {
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ cursor: pointer;
+ border: 1px solid light-dark(#c0c0c0, var(--s2-gray-500, #6e6e6e));
+ background: light-dark(#f5f5f5, var(--s2-gray-200, #3d3d3d));
+ color: inherit;
+}
+
+.nx-focal-point-dialog__btn--accent {
+ background: var(--s2-blue-800, #1473e6);
+ color: #fff;
+ border-color: var(--s2-blue-800, #1473e6);
+}
+
+.nx-focal-point-dialog__btn--danger {
+ border-color: light-dark(#d93c3c, #ff6b6b);
+ color: light-dark(#b30, #ff9d9d);
+}
+
+.nx-focal-point-dialog .focal-point-content {
+ --focal-color: #ec4899;
+ --focal-glow: rgb(236 72 153 / 50%);
+
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ width: 100%;
+ padding: 1rem 1.25rem;
+ box-sizing: border-box;
+}
+
+.nx-focal-point-dialog .focal-point-image-container {
+ position: relative;
+ width: 100%;
+ min-height: 300px;
+ max-height: 500px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: light-dark(#f5f5f5, var(--s2-gray-200, #3d3d3d));
+ border: 1px solid light-dark(#ddd, var(--s2-gray-400, #6e6e6e));
+ border-radius: 4px;
+ overflow: hidden;
+ cursor: grab;
+}
+
+.nx-focal-point-dialog .focal-point-image {
+ width: 100%;
+ height: auto;
+ max-height: 500px;
+ display: block;
+ user-select: none;
+ object-fit: contain;
+}
+
+.nx-focal-point-dialog .focal-point-indicator {
+ position: absolute;
+ width: 40px;
+ height: 40px;
+ margin-left: -20px;
+ margin-top: -20px;
+ pointer-events: none;
+ z-index: 100;
+}
+
+.nx-focal-point-dialog .focal-point-inner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background: var(--focal-color);
+ border: 3px solid white;
+ box-sizing: border-box;
+ cursor: grab;
+ box-shadow: 0 0 0 2px var(--focal-color), 0 4px 12px var(--focal-glow);
+ animation: nx-focal-pulse 2s infinite;
+}
+
+.nx-focal-point-dialog .focal-point-inner::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 8px;
+ height: 8px;
+ background: white;
+ border-radius: 50%;
+}
+
+@keyframes nx-focal-pulse {
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+
+ 50% {
+ transform: scale(1.1);
+ opacity: 0.9;
+ }
+}
+
+.nx-focal-point-dialog .focal-point-coords {
+ display: flex;
+ gap: 24px;
+ justify-content: center;
+ align-items: center;
+ padding: 16px 24px;
+ background: light-dark(#fafafa, var(--s2-gray-200, #3d3d3d));
+ border-radius: 4px;
+ border: 1px solid light-dark(#e0e0e0, var(--s2-gray-400, #6e6e6e));
+}
+
+.nx-focal-point-dialog .focal-point-coords label {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 14px;
+ font-weight: 600;
+ color: light-dark(#555, var(--s2-gray-700, #cacaca));
+ min-width: 120px;
+}
+
+.nx-focal-point-dialog .focal-point-input {
+ width: 90px;
+ padding: 8px 12px;
+ border: 1px solid light-dark(#d0d0d0, var(--s2-gray-500, #5c5c5c));
+ border-radius: 4px;
+ font-size: 15px;
+ text-align: center;
+ background: light-dark(#fafafa, var(--s2-gray-75, #2c2c2c));
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ color: inherit;
+ font-weight: 500;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
+}
diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
new file mode 100644
index 00000000..0f02fea0
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js
@@ -0,0 +1,318 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper';
+import { getNx } from '../../../scripts/utils.js';
+import {
+ updateDocument, updateCursors, getInstrumentedHTML,
+ editorHtmlChange, editorSelectChange, getEditor,
+} from '../editor-utils/editor-utils.js';
+import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js';
+import {
+ editorDocCanLoad,
+ sourceUrlFromEditorCtx,
+ controllerPathnameFromEditorCtx,
+ editorDocRenderPhase,
+} from './utils/ctx.js';
+import { subscribeCollabUserList } from './utils/awareness-users.js';
+import {
+ prefetchWysiwygCookiesIfSignedIn,
+ wireQuickEditControllerPort,
+} from './utils/quick-edit-host.js';
+import { initIms as loadIms } from '../../shared/utils.js';
+import initProse from './prose.js';
+import { createTrackingPlugin } from '../editor-utils/prose-diff.js';
+import { resolveEditorDocSession } from './utils/load-editor-doc.js';
+import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount.js';
+import { teardownEditorDocResources } from './utils/teardown.js';
+import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js';
+import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+export class EwEditorDoc extends LitElement {
+ static properties = {
+ ctx: { type: Object },
+ quickEditPort: { type: Object },
+ _error: { state: true },
+ };
+
+ willUpdate(changed) {
+ super.willUpdate(changed);
+ if (changed.has('ctx')) {
+ this.quickEditPort = undefined;
+ this._teardown();
+ this._error = undefined;
+ this._lastDocBlockIndex = undefined;
+ editorHtmlChange.emit('');
+ }
+ }
+
+ _clearControllerPort() {
+ const port = this._controllerCtx?.port;
+ if (port) {
+ port.onmessage = null;
+ port.close();
+ }
+ this._controllerCtx = undefined;
+ }
+
+ _emitCollabUsers(users) {
+ this.dispatchEvent(new CustomEvent('da-collab-users', {
+ bubbles: true,
+ composed: true,
+ detail: { users },
+ }));
+ }
+
+ _emitHtmlChange() {
+ const { view } = this._proseContext ?? {};
+ if (!view) return;
+ editorHtmlChange.emit(getInstrumentedHTML(view));
+ }
+
+ _emitUndoState() {
+ const mgr = this._proseContext?.undoManager;
+ const canUndo = mgr ? mgr.undoStack.length > 0 : false;
+ const canRedo = mgr ? mgr.redoStack.length > 0 : false;
+ this.dispatchEvent(new CustomEvent('nx-editor-undo-state', {
+ bubbles: true,
+ composed: true,
+ detail: { canUndo, canRedo },
+ }));
+ }
+
+ _observeUndoManager(mgr) {
+ this._stopObservingUndoManager();
+ if (!mgr) return;
+ this._undoStackHandler = () => this._emitUndoState();
+ mgr.on('stack-item-added', this._undoStackHandler);
+ mgr.on('stack-item-popped', this._undoStackHandler);
+ }
+
+ _stopObservingUndoManager() {
+ const mgr = this._proseContext?.undoManager;
+ if (!mgr || !this._undoStackHandler) return;
+ mgr.off('stack-item-added', this._undoStackHandler);
+ mgr.off('stack-item-popped', this._undoStackHandler);
+ this._undoStackHandler = undefined;
+ }
+
+ _scrollDocToBlock(blockIndex) {
+ if (blockIndex < 0) return;
+ const { view } = this._proseContext ?? {};
+ if (!view) return;
+ const positions = getBlockPositions(view);
+ const pos = positions[blockIndex];
+ if (pos == null) return;
+ this._lastDocBlockIndex = blockIndex;
+ const sel = NodeSelection.create(view.state.doc, pos);
+ view.dispatch(view.state.tr.setSelection(sel).scrollIntoView());
+ }
+
+ undo() {
+ const { view } = this._proseContext ?? {};
+ if (view) yUndo(view.state, view.dispatch);
+ }
+
+ redo() {
+ const { view } = this._proseContext ?? {};
+ if (view) yRedo(view.state, view.dispatch);
+ }
+
+ _setupController() {
+ const { view, wsProvider } = this._proseContext ?? {};
+ if (!this.quickEditPort || !view || !wsProvider) return;
+ if (this._controllerCtx?.port === this.quickEditPort) return;
+
+ this._clearControllerPort();
+ prefetchWysiwygCookiesIfSignedIn(this.ctx);
+
+ const { org, repo } = this.ctx ?? {};
+ this._controllerCtx = {
+ view,
+ wsProvider,
+ port: this.quickEditPort,
+ iframe: this._wysiwygIframe,
+ suppressRerender: false,
+ lastBlockIndex: undefined,
+ owner: org,
+ repo,
+ path: controllerPathnameFromEditorCtx(this.ctx),
+ getToken: async () => (await loadIms())?.accessToken?.token ?? null,
+ };
+ wireQuickEditControllerPort(this._controllerCtx);
+ }
+
+ _setupAwareness(wsProvider) {
+ if (this._awarenessOff) {
+ this._awarenessOff();
+ this._awarenessOff = undefined;
+ }
+ this._awarenessOff = subscribeCollabUserList(wsProvider, (users) => {
+ this._emitCollabUsers(users);
+ });
+ }
+
+ _setEditable(editable) {
+ this.requestUpdate();
+ afterNextPaint(() => {
+ const pm = this.shadowRoot?.querySelector('.ew-editor-doc-mount .ProseMirror');
+ if (pm) pm.contentEditable = editable ? 'true' : 'false';
+ });
+ }
+
+ _teardown() {
+ this._stopObservingUndoManager();
+ const { wsProvider, view, proseEl } = this._proseContext ?? {};
+ teardownEditorDocResources({
+ clearPortHandler: () => this._clearControllerPort(),
+ awarenessOff: this._awarenessOff,
+ wsProvider,
+ view,
+ proseEl,
+ onCollabUsersCleared: () => this._emitCollabUsers([]),
+ });
+ this._awarenessOff = undefined;
+ this._proseContext = undefined;
+ }
+
+ async _loadEditor() {
+ if (!editorDocCanLoad(this.ctx)) {
+ return;
+ }
+
+ const sourceUrl = sourceUrlFromEditorCtx(this.ctx);
+
+ const session = await resolveEditorDocSession(sourceUrl);
+ if (!session.ok) {
+ this._error = session.error;
+ return;
+ }
+
+ try {
+ const { token, permissions } = session;
+ const { proseEl, wsProvider, view, ydoc, undoManager } = await initProse({
+ path: sourceUrl,
+ permissions,
+ setEditable: (editable) => this._setEditable(editable),
+ getToken: () => token,
+ extraPlugins: [
+ createExtensionsBridgePlugin(),
+ createTrackingPlugin(
+ () => {
+ const body = this._controllerCtx
+ ? updateDocument(this._controllerCtx)
+ : getInstrumentedHTML(this._proseContext?.view);
+ if (body) editorHtmlChange.emit(body);
+ },
+ () => { if (this._controllerCtx) updateCursors(this._controllerCtx); },
+ (data) => { if (this._controllerCtx) getEditor(data, this._controllerCtx); },
+ (pmView) => {
+ const blockIndex = getActiveBlockIndex(pmView);
+ if (blockIndex === this._lastDocBlockIndex) return;
+ this._lastDocBlockIndex = blockIndex;
+ const explicit = pmView.state.selection instanceof NodeSelection;
+ editorSelectChange.emit({ blockIndex, source: 'doc', explicit });
+ },
+ ),
+ ],
+ });
+
+ this._proseContext = { proseEl, wsProvider, view, ydoc, undoManager };
+ this._setupAwareness(wsProvider);
+ this._observeUndoManager(undoManager);
+ this._emitHtmlChange();
+
+ this._setupController();
+ } catch (e) {
+ this._error = e?.message || 'Failed to load editor';
+ this._proseContext = undefined;
+ return;
+ }
+
+ this.requestUpdate();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this._onCanvasEditorActive = (e) => {
+ const view = e.detail?.view;
+ this.hidden = view === 'layout';
+ hideSelectionToolbar();
+ };
+ this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive);
+ this._onWysiwygPortReady = (e) => {
+ const { port, iframe } = e.detail ?? {};
+ if (port) {
+ this._wysiwygIframe = iframe;
+ this.quickEditPort = port;
+ }
+ };
+ this.parentElement?.addEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady);
+ this._unsubscribeSelect = editorSelectChange
+ .subscribe(({ blockIndex, source }) => {
+ if (source !== 'doc') this._scrollDocToBlock(blockIndex);
+ });
+ }
+
+ disconnectedCallback() {
+ this.parentElement?.removeEventListener('nx-canvas-editor-active', this._onCanvasEditorActive);
+ this.parentElement?.removeEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady);
+ this._unsubscribeSelect?.();
+ this._teardown();
+ super.disconnectedCallback();
+ }
+
+ updated(changed) {
+ super.updated(changed);
+ if (changed.has('ctx')) {
+ this._loadEditor();
+ }
+ if (changed.has('quickEditPort')) {
+ if (this.quickEditPort && this._proseContext?.view) {
+ this._setupController();
+ } else if (!this.quickEditPort) {
+ this._clearControllerPort();
+ }
+ }
+ const { proseEl } = this._proseContext ?? {};
+ if (proseEl) {
+ ensureProseMountedInShadow({ shadowRoot: this.shadowRoot, proseEl });
+ }
+ }
+
+ render() {
+ const phase = editorDocRenderPhase(this.ctx, {
+ error: this._error,
+ hasEditorView: Boolean(this._proseContext?.view),
+ });
+ if (phase === 'incomplete') {
+ return html`
+
+
+ Set hash to #/org/site and open an HTML file to edit.
+
+
+ `;
+ }
+ if (phase === 'error') {
+ return html`
+
+ `;
+ }
+ if (phase === 'loading') {
+ return nothing;
+ }
+ return html`
+
+ `;
+ }
+}
+
+customElements.define('ew-editor-doc', EwEditorDoc);
diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js
new file mode 100644
index 00000000..c21820ed
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js
@@ -0,0 +1,84 @@
+import { Plugin } from 'da-y-wrapper';
+import { getNx } from '../../../../scripts/utils.js';
+import { daFetch } from '../../../shared/utils.js';
+import { getSourceUploadContext } from './sourceUploadContext.js';
+
+const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`);
+
+const FPO_IMG_URL = '/blocks/edit/img/fpo.svg';
+
+function makeHash(string) {
+ return Math.abs(string.split('').reduce((hash, char) => (
+ // eslint-disable-next-line no-bitwise -- same hash as da.live paste uploader
+ char.charCodeAt(0) + (hash << 6) + (hash << 16) - hash
+ ), 0));
+}
+
+/**
+ * @param {{
+ * getSourceUrl: () => string | null,
+ * getEditorView: () => import('prosemirror-view').EditorView | null,
+ * }} opts
+ */
+export default function base64Uploader({ getSourceUrl, getEditorView }) {
+ return new Plugin({
+ props: {
+ transformPastedHTML: (html) => {
+ try {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const dataImgs = [...doc.querySelectorAll('[src^="data:image"]')];
+ if (!dataImgs.length) {
+ return html;
+ }
+
+ const details = getSourceUploadContext(getSourceUrl() ?? '');
+ if (!details) return html;
+
+ const imagePaths = [];
+ const uploadPromises = [];
+
+ dataImgs.forEach((img) => {
+ const src = img.getAttribute('src');
+ let ext = src.replace('data:image/', '').split(';base64')[0];
+ if (ext === 'jpeg') ext = 'jpg';
+ const path = `${details.parent}/.${details.name}/wp${makeHash(src)}.${ext}`;
+ const fpoSrc = `${FPO_IMG_URL}#${DA_CONTENT}${path}`;
+ img.setAttribute('src', fpoSrc);
+ imagePaths.push(fpoSrc);
+
+ uploadPromises.push((async () => {
+ const resp = await fetch(src);
+ const blob = await resp.blob();
+ const body = new FormData();
+ body.append('data', blob);
+ await daFetch(`${DA_ADMIN}/source${path}`, { body, method: 'POST' });
+ })());
+ });
+
+ Promise.all(uploadPromises).then(() => {
+ const view = getEditorView();
+ if (!view) return;
+ const { tr } = view.state;
+
+ view.state.doc.descendants((node, pos) => {
+ if (node.type.name === 'image' && imagePaths.includes(node.attrs.src)) {
+ const newAttrs = { src: node.attrs.src.split('#')[1] };
+ tr.setNodeMarkup(pos, null, { ...node.attrs, ...newAttrs });
+ }
+ });
+
+ view.dispatch(tr);
+ });
+
+ const serializer = new XMLSerializer();
+ return serializer.serializeToString(doc);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error handling Base64 images:', error);
+ return html;
+ }
+ },
+ },
+ });
+}
diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js
new file mode 100644
index 00000000..07d9fb86
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js
@@ -0,0 +1,57 @@
+import { Plugin, TextSelection } from 'da-y-wrapper';
+import { daFetch } from '../../../shared/utils.js';
+import { getSourceUploadContext } from './sourceUploadContext.js';
+
+const FPO_IMG_URL = '/blocks/edit/img/fpo.svg';
+const SUPPORTED_FILES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif'];
+
+/**
+ * @param {import('prosemirror-model').Schema} schema
+ * @param {() => string | null} getSourceUrl
+ */
+export default function imageDrop(schema, getSourceUrl) {
+ return new Plugin({
+ props: {
+ handleDOMEvents: {
+ drop: (view, event) => {
+ event.preventDefault();
+
+ const { files } = event.dataTransfer;
+ if (files.length === 0) return false;
+
+ const details = getSourceUploadContext(getSourceUrl() ?? '');
+ if (!details) return false;
+
+ ([...files]).forEach(async (file) => {
+ if (!SUPPORTED_FILES.some((type) => type === file.type)) return;
+
+ const fpo = schema.nodes.image.create({ src: FPO_IMG_URL, style: 'width: 180px' });
+ view.dispatch(view.state.tr.replaceSelectionWith(fpo).scrollIntoView());
+
+ const { $from } = view.state.selection;
+
+ const url = `${details.origin}/source${details.parent}/.${details.name}/${file.name}`;
+
+ const formData = new FormData();
+ formData.append('data', file);
+ const opts = { method: 'PUT', body: formData };
+ const resp = await daFetch(url, opts);
+ if (!resp.ok) return;
+ const json = await resp.json();
+
+ const docImg = document.createElement('img');
+ docImg.addEventListener('load', () => {
+ const fpoSelection = TextSelection.create(view.state.doc, $from.pos - 1, $from.pos);
+ const ts = view.state.tr.setSelection(fpoSelection);
+ const img = schema.nodes.image.create({ src: json.source.contentUrl });
+ const tr = ts.replaceSelectionWith(img).scrollIntoView();
+ view.dispatch(tr);
+ });
+ docImg.src = json.source.contentUrl;
+ });
+ return true;
+ },
+ },
+ },
+ });
+}
diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js
new file mode 100644
index 00000000..085a1dc2
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails).
+ */
+import { getNx } from '../../../../scripts/utils.js';
+
+const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`);
+
+/**
+ * @param {string} sourceUrl - e.g. https://admin.da.live/source/org/repo/path/doc.html
+ * @returns {{ origin: string, parent: string, name: string } | null}
+ */
+export function getSourceUploadContext(sourceUrl) {
+ if (!sourceUrl || typeof sourceUrl !== 'string') return null;
+ try {
+ const u = new URL(sourceUrl);
+ const mark = '/source/';
+ const idx = u.pathname.indexOf(mark);
+ if (idx === -1) return null;
+ const rest = u.pathname.slice(idx + mark.length);
+ const segments = rest.split('/').filter(Boolean);
+ if (segments.length === 0) return null;
+ const lastSeg = segments[segments.length - 1];
+ const name = lastSeg.replace(/\.html?$/i, '');
+ const parentSegments = segments.slice(0, -1);
+ const parent = parentSegments.length ? `/${parentSegments.join('/')}` : '/';
+ return { origin: DA_ADMIN, parent, name };
+ } catch {
+ return null;
+ }
+}
diff --git a/blocks/canvas/ew-editor-doc/prose.js b/blocks/canvas/ew-editor-doc/prose.js
new file mode 100644
index 00000000..03e318e8
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/prose.js
@@ -0,0 +1,173 @@
+/* eslint-disable import/no-unresolved -- importmap + da.live prose plugins */
+import {
+ EditorState,
+ EditorView,
+ fixTables,
+ keymap,
+ baseKeymap,
+ Y,
+ WebsocketProvider,
+ ySyncPlugin,
+ yCursorPlugin,
+ yUndoPlugin,
+ yUndoPluginKey,
+ yUndo,
+ yRedo,
+ buildKeymap,
+ tableEditing,
+ columnResizing,
+ gapCursor,
+ liftListItem,
+ sinkListItem,
+} from 'da-y-wrapper';
+import { getSchema } from 'da-parser';
+import {
+ getEnterInputRulesPlugin,
+ getURLInputRulesPlugin,
+ getListInputRulesPlugin,
+ handleTableBackspace,
+ handleTableTab,
+} from '../../edit/prose/plugins/keyHandlers.js';
+import { getHeadingKeymap } from '../../edit/prose/plugins/menu/menu.js';
+import { createSlashMenuPlugin } from './slash-menu/slash-menu.js';
+import { createSelectionToolbarPlugin } from '../editor-utils/selection-toolbar.js';
+import codemark from '../../edit/prose/plugins/codemark.js';
+import tableSelectHandle from '../../edit/prose/plugins/tableSelectHandle.js';
+import imageDrop from './prose-plugins/imageDrop.js';
+import imageFocalPoint from '../../edit/prose/plugins/imageFocalPoint.js';
+import sectionPasteHandler from '../../edit/prose/plugins/sectionPasteHandler.js';
+import base64Uploader from './prose-plugins/base64Uploader.js';
+import { getNx } from '../../../scripts/utils.js';
+import { generateColor, getCollabIdentity } from './utils/collab.js';
+
+const { DA_ADMIN, DA_COLLAB } = await import(`${getNx()}/utils/utils.js`);
+
+function registerErrorHandler(ydoc) {
+ ydoc.on('update', () => {
+ const errorMap = ydoc.getMap('error');
+ if (errorMap && errorMap.size > 0) {
+ // eslint-disable-next-line no-console
+ console.log('Error from server', JSON.stringify(errorMap));
+ errorMap.clear();
+ }
+ });
+}
+
+function addSyncedListener(wsProvider, canWrite, setEditable) {
+ const handleSynced = (isSynced) => {
+ if (isSynced) {
+ if (canWrite && typeof setEditable === 'function') {
+ setEditable(true);
+ }
+ wsProvider.off('synced', handleSynced);
+ }
+ };
+ wsProvider.on('synced', handleSynced);
+}
+
+export default async function initProse({
+ path, permissions, setEditable, getToken,
+ extraPlugins = [],
+}) {
+ const editor = document.createElement('div');
+ editor.className = 'da-prose-mirror';
+ editor.setAttribute('data-gramm', 'false');
+ editor.setAttribute('data-gramm_editor', 'false');
+
+ const schema = getSchema();
+ const ydoc = new Y.Doc();
+
+ const server = DA_COLLAB;
+ const roomName = `${DA_ADMIN}${new URL(path).pathname}`;
+
+ const wsOpts = { protocols: ['yjs'] };
+ if (typeof getToken === 'function') {
+ const t = getToken();
+ if (t) wsOpts.params = { Authorization: `Bearer ${t}` };
+ }
+
+ const canWrite = permissions.some((permission) => permission === 'write');
+
+ const wsProvider = new WebsocketProvider(server, roomName, ydoc, wsOpts);
+ wsProvider.maxBackoffTime = 30000;
+
+ addSyncedListener(wsProvider, canWrite, setEditable);
+ registerErrorHandler(ydoc);
+
+ const yXmlFragment = ydoc.getXmlFragment('prosemirror');
+
+ const identity = await getCollabIdentity();
+ if (typeof getToken === 'function' && getToken() && identity) {
+ wsProvider.awareness.setLocalStateField('user', {
+ color: generateColor(identity.colorSeed),
+ name: identity.name,
+ id: identity.id,
+ });
+ } else {
+ wsProvider.awareness.setLocalStateField('user', {
+ color: generateColor(`${wsProvider.awareness.clientID}`),
+ name: 'Anonymous',
+ id: `anonymous-${wsProvider.awareness.clientID}`,
+ });
+ }
+
+ /** @type {import('prosemirror-view').EditorView | null} */
+ let viewRef = null;
+ const dispatch = (tr) => { if (viewRef) viewRef.dispatch(tr); };
+
+ /* Keymap order matches da.live prose/index.js: baseKeymap after buildKeymap +
+ * handleTableBackspace (fixes list Enter + table NodeSelection + Backspace). */
+ const plugins = [
+ ySyncPlugin(yXmlFragment),
+ yCursorPlugin(wsProvider.awareness),
+ yUndoPlugin(),
+ tableSelectHandle(),
+ imageDrop(schema, () => path),
+ sectionPasteHandler(schema),
+ base64Uploader({ getSourceUrl: () => path, getEditorView: () => viewRef }),
+ columnResizing(),
+ getEnterInputRulesPlugin(dispatch),
+ getURLInputRulesPlugin(),
+ getListInputRulesPlugin(schema),
+ keymap(buildKeymap(schema)),
+ keymap({ Backspace: handleTableBackspace }),
+ keymap(baseKeymap),
+ codemark(),
+ keymap({
+ 'Mod-z': (state) => yUndo(state) || false,
+ 'Mod-y': (state) => yRedo(state) || false,
+ 'Mod-Shift-z': (state) => yRedo(state) || false,
+ ...getHeadingKeymap(schema),
+ }),
+ keymap({
+ Tab: handleTableTab(1),
+ 'Shift-Tab': handleTableTab(-1),
+ }),
+ keymap({
+ Tab: sinkListItem(schema.nodes.list_item),
+ 'Shift-Tab': liftListItem(schema.nodes.list_item),
+ }),
+ gapCursor(),
+ tableEditing({ allowTableNodeSelection: true }),
+ ...extraPlugins,
+ ];
+
+ if (canWrite) {
+ plugins.unshift(createSlashMenuPlugin(), createSelectionToolbarPlugin());
+ plugins.push(imageFocalPoint());
+ }
+
+ let state = EditorState.create({ schema, plugins });
+
+ const fix = fixTables(state);
+ if (fix) state = state.apply(fix.setMeta('addToHistory', false));
+
+ viewRef = new EditorView(editor, {
+ state,
+ editable() { return canWrite; },
+ });
+
+ const undoManager = yUndoPluginKey.getState(viewRef.state)?.undoManager ?? null;
+
+ return { proseEl: editor, wsProvider, view: viewRef, ydoc, undoManager };
+}
diff --git a/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js
new file mode 100644
index 00000000..a1b44eec
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js
@@ -0,0 +1,179 @@
+/* eslint-disable import/no-unresolved -- importmap */
+import { Plugin } from 'da-y-wrapper';
+import { getNx } from '../../../../scripts/utils.js';
+import { slashMenuItemsForQuery, COMMAND_BY_ID } from '../../editor-utils/command-defs.js';
+
+await import(`${getNx()}/blocks/shared/menu/menu.js`);
+
+function inTopLevelParagraph($from) {
+ if ($from.parent.type.name !== 'paragraph') return false;
+ if ($from.depth < 1) return false;
+ return $from.node($from.depth - 1).type.name === 'doc';
+}
+
+function getSlashContext(state) {
+ const { $from } = state.selection;
+ if (!inTopLevelParagraph($from)) return null;
+
+ const paraStart = $from.start();
+ const head = state.selection.from;
+ if (head <= paraStart) return null;
+
+ const prefix = state.doc.textBetween(paraStart, head, '\ufffc', '\ufffc');
+ if (!prefix.startsWith('/')) return null;
+
+ const query = prefix.slice(1);
+ if (/\s/.test(query)) return null;
+
+ return { query, anchorPos: paraStart };
+}
+
+function shouldShowSlashHint(state) {
+ const { $from } = state.selection;
+ return (
+ inTopLevelParagraph($from)
+ && $from.parentOffset === 0
+ && $from.parent.content.size === 0
+ && !getSlashContext(state)
+ );
+}
+
+function setup(container, view) {
+ const anchor = document.createElement('span');
+ anchor.style.cssText = 'position:absolute;width:0;height:0;pointer-events:none';
+ container.append(anchor);
+
+ const menu = document.createElement('nx-menu');
+ menu.ignoreFocus = true;
+ menu.scoped = true;
+ menu.items = slashMenuItemsForQuery('');
+ container.append(menu);
+
+ menu.addEventListener('select', (e) => {
+ const run = COMMAND_BY_ID.get(e.detail.id)?.apply;
+ const { state } = view;
+ const slash = getSlashContext(state);
+ if (slash && run) {
+ const { anchorPos } = slash;
+ const head = state.selection.from;
+ const tr = state.tr.delete(anchorPos, head);
+ view.dispatch(tr);
+ run(view);
+ }
+ view.focus();
+ });
+
+ const scrollEl = container.closest('.ew-editor-doc');
+ const onScroll = () => { if (menu.open) menu.reposition(); };
+ scrollEl?.addEventListener('scroll', onScroll, { passive: true });
+
+ return { menu, anchor, scrollEl, onScroll };
+}
+
+function positionAnchor(view, anchor, pos) {
+ const coords = view.coordsAtPos(pos);
+ const rect = anchor.offsetParent.getBoundingClientRect();
+ anchor.style.left = `${coords.left - rect.left}px`;
+ anchor.style.top = `${coords.bottom - rect.top}px`;
+}
+
+function syncSlashHint(view, ctxRef) {
+ const container = view.dom.parentElement;
+ if (!container) return;
+
+ if (!shouldShowSlashHint(view.state)) {
+ if (ctxRef.hintEl) ctxRef.hintEl.style.display = 'none';
+ return;
+ }
+
+ if (!ctxRef.hintEl) {
+ const hint = document.createElement('span');
+ hint.textContent = 'Tap \'/\' to insert';
+ hint.setAttribute('aria-hidden', 'true');
+ hint.className = 'da-slash-hint';
+ container.append(hint);
+ ctxRef.hintEl = hint;
+ }
+
+ const { hintEl } = ctxRef;
+ const pos = view.state.selection.$from.start();
+ const coords = view.coordsAtPos(pos);
+ const containerRect = container.getBoundingClientRect();
+ hintEl.style.left = `${coords.left - containerRect.left + 3}px`;
+ hintEl.style.top = `${coords.top - containerRect.top}px`;
+ hintEl.style.display = '';
+}
+
+function syncSlashUi(view, ctxRef) {
+ syncSlashHint(view, ctxRef);
+
+ const container = view.dom.parentElement;
+ if (!container) return;
+
+ const slash = getSlashContext(view.state);
+
+ if (!slash) {
+ ctxRef.ctx?.menu.close();
+ return;
+ }
+
+ const items = slashMenuItemsForQuery(slash.query);
+ if (!items.length) {
+ ctxRef.ctx?.menu.close();
+ return;
+ }
+
+ if (!ctxRef.ctx) ctxRef.ctx = setup(container, view);
+ const { menu, anchor } = ctxRef.ctx;
+ positionAnchor(view, anchor, slash.anchorPos);
+ menu.items = items;
+ if (!menu.open) {
+ menu.show({ anchor, placement: 'auto' });
+ }
+}
+
+function destroySlashUi(ctxRef) {
+ ctxRef.hintEl?.remove();
+ ctxRef.hintEl = null;
+ const { ctx } = ctxRef;
+ if (!ctx) return;
+ ctx.menu.close();
+ ctx.scrollEl?.removeEventListener('scroll', ctx.onScroll);
+ ctx.anchor.remove();
+ ctx.menu.remove();
+ ctxRef.ctx = null;
+}
+
+export function createSlashMenuPlugin() {
+ const ctxRef = {};
+
+ return new Plugin({
+ view(editorView) {
+ const onKeyDown = () => {
+ syncSlashUi(editorView, ctxRef);
+ };
+ editorView.dom.addEventListener('keydown', onKeyDown);
+
+ return {
+ update(editorView_) {
+ // Paste, collab, pointer, and any transaction not preceded by this DOM keydown path
+ syncSlashUi(editorView_, ctxRef);
+ },
+ destroy() {
+ editorView.dom.removeEventListener('keydown', onKeyDown);
+ destroySlashUi(ctxRef);
+ },
+ };
+ },
+ props: {
+ handleKeyDown(view, event) {
+ const { ctx } = ctxRef;
+ if (!ctx?.menu.open) return false;
+ const keys = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'];
+ if (!keys.includes(event.key)) return false;
+ ctx.menu.handleKey(event.key);
+ return true;
+ },
+ },
+ });
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/awareness-users.js b/blocks/canvas/ew-editor-doc/utils/awareness-users.js
new file mode 100644
index 00000000..17eaf340
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/awareness-users.js
@@ -0,0 +1,29 @@
+export function subscribeCollabUserList(wsProvider, onList) {
+ const users = new Set();
+ const dispatch = () => {
+ const self = wsProvider.awareness.clientID;
+ const awarenessStates = wsProvider.awareness.getStates();
+ const userMap = new Map();
+ [...users].forEach((u, i) => {
+ if (u === self) return;
+ const userInfo = awarenessStates.get(u)?.user;
+ if (!userInfo?.name) {
+ userMap.set(`anonymous-${u}`, 'Anonymous');
+ } else {
+ userMap.set(`${userInfo.id}-${i}`, userInfo.name);
+ }
+ });
+ onList([...userMap.values()].sort());
+ };
+ const onUpdate = (delta) => {
+ delta.added.forEach((u) => users.add(u));
+ delta.updated.forEach((u) => users.add(u));
+ delta.removed.forEach((u) => users.delete(u));
+ dispatch();
+ };
+ wsProvider.awareness.on('update', onUpdate);
+ dispatch();
+ return () => {
+ wsProvider.awareness.off('update', onUpdate);
+ };
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/collab.js b/blocks/canvas/ew-editor-doc/utils/collab.js
new file mode 100644
index 00000000..985ffc03
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/collab.js
@@ -0,0 +1,40 @@
+import { initIms as loadIms } from '../../../shared/utils.js';
+
+export function generateColor(name, hRange = [0, 360], sRange = [60, 80], lRange = [40, 60]) {
+ let hash = 0;
+ for (let i = 0; i < name.length; i += 1) {
+ // eslint-disable-next-line no-bitwise
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ hash = Math.abs(hash);
+ const normalizeHash = (min, max) => Math.floor((hash % (max - min)) + min);
+ const h = normalizeHash(hRange[0], hRange[1]);
+ const s = normalizeHash(sRange[0], sRange[1]);
+ const l = normalizeHash(lRange[0], lRange[1]) / 100;
+ const a = (s * Math.min(l, 1 - l)) / 100;
+ const f = (n) => {
+ const k = (n + h / 30) % 12;
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
+ return Math.round(255 * color).toString(16).padStart(2, '0');
+ };
+ return `#${f(0)}${f(8)}${f(4)}`;
+}
+
+export async function getCollabIdentity() {
+ try {
+ const ims = await loadIms();
+ if (ims?.anonymous) return null;
+ const name = (ims?.displayName || ims?.name || '').trim();
+ const id = ims?.userId || ims?.email || '';
+ if (name && id) {
+ return {
+ name,
+ id,
+ colorSeed: ims?.email || ims?.userId || name,
+ };
+ }
+ } catch {
+ /* ignore */
+ }
+ return null;
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/ctx.js b/blocks/canvas/ew-editor-doc/utils/ctx.js
new file mode 100644
index 00000000..975da88e
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/ctx.js
@@ -0,0 +1,29 @@
+import { buildSourceUrl } from './source.js';
+
+export function sourceUrlFromEditorCtx(ctx) {
+ return buildSourceUrl(ctx?.path);
+}
+
+export function editorCtxHasOrgRepoPath(ctx) {
+ const { org, repo, path } = ctx ?? {};
+ return Boolean(org && repo && path);
+}
+
+export function editorDocCanLoad(ctx) {
+ return editorCtxHasOrgRepoPath(ctx) && Boolean(sourceUrlFromEditorCtx(ctx));
+}
+
+export function controllerPathnameFromEditorCtx(ctx) {
+ const docPath = ctx?.path;
+ if (!docPath || typeof docPath !== 'string') return '/';
+ const segments = docPath.replace(/^\//, '').split('/').filter(Boolean);
+ const withoutOrgRepo = segments.slice(2).join('/');
+ return withoutOrgRepo ? `/${withoutOrgRepo}` : '/';
+}
+
+export function editorDocRenderPhase(ctx, { error, hasEditorView }) {
+ if (!editorDocCanLoad(ctx)) return 'incomplete';
+ if (error) return 'error';
+ if (!hasEditorView) return 'loading';
+ return 'editor';
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js
new file mode 100644
index 00000000..40a9c8cf
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js
@@ -0,0 +1,19 @@
+import { checkDoc } from './source.js';
+import { initIms } from '../../../shared/utils.js';
+
+export async function resolveEditorDocSession(sourceUrl) {
+ const ims = await initIms();
+ const token = ims?.accessToken?.token ?? null;
+ if (ims?.anonymous || !token) {
+ return { ok: false, error: 'Sign in required' };
+ }
+
+ const resp = await checkDoc(sourceUrl);
+ if (!resp.ok && resp.status !== 404) {
+ const error = resp.status === 401 ? 'Sign in required' : `Failed to load (${resp.status})`;
+ return { ok: false, error };
+ }
+
+ const permissions = resp.permissions || ['read'];
+ return { ok: true, token, permissions };
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js b/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js
new file mode 100644
index 00000000..60040b62
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js
@@ -0,0 +1,27 @@
+import { createControllerOnMessage } from '../../ew-editor-wysiwyg/quick-edit-controller.js';
+import { getNx } from '../../../../scripts/utils.js';
+import { updateDocument, updateCursors, fetchWysiwygCookie } from '../../editor-utils/editor-utils.js';
+
+export function prefetchWysiwygCookiesIfSignedIn(ctx) {
+ const { org, repo } = ctx ?? {};
+ if (!org || !repo) return;
+ (async () => {
+ const { loadIms } = await import(`${getNx()}/utils/ims.js`);
+ const token = (await loadIms())?.accessToken?.token;
+ if (token) {
+ await fetchWysiwygCookie({ org, repo, token }).catch(() => {});
+ }
+ })().catch(() => {});
+}
+
+export function wireQuickEditControllerPort(controllerCtx) {
+ controllerCtx.port.onmessage = createControllerOnMessage(controllerCtx);
+ const sendInitialBodyAndCursors = () => {
+ if (!controllerCtx.port) return;
+ updateDocument(controllerCtx);
+ updateCursors(controllerCtx);
+ };
+ requestAnimationFrame(() => {
+ requestAnimationFrame(sendInitialBodyAndCursors);
+ });
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/shadow-mount.js b/blocks/canvas/ew-editor-doc/utils/shadow-mount.js
new file mode 100644
index 00000000..59456c6d
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/shadow-mount.js
@@ -0,0 +1,19 @@
+export function afterNextPaint(cb) {
+ Promise.resolve().then(() => requestAnimationFrame(cb));
+}
+
+export function ensureProseMountedInShadow({
+ shadowRoot,
+ proseEl,
+ mountSelector = '.ew-editor-doc-mount',
+}) {
+ const mount = shadowRoot?.querySelector(mountSelector);
+ if (!mount || mount.contains(proseEl)) return;
+ mount.appendChild(proseEl);
+ if (shadowRoot && !shadowRoot.createRange) {
+ shadowRoot.createRange = () => document.createRange();
+ }
+ if (shadowRoot && !shadowRoot.getSelection) {
+ shadowRoot.getSelection = () => document.getSelection();
+ }
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/source.js b/blocks/canvas/ew-editor-doc/utils/source.js
new file mode 100644
index 00000000..b22ccec4
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/source.js
@@ -0,0 +1,23 @@
+import { getNx } from '../../../../scripts/utils.js';
+import { daFetch } from '../../../shared/utils.js';
+
+const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`);
+
+export function buildSourceUrl(path) {
+ if (!path || typeof path !== 'string') return null;
+ const trimmed = path.replace(/^\//, '').trim();
+ if (!trimmed) return null;
+ return `${DA_ADMIN}/source/${trimmed}.html`;
+}
+
+export function parsePermissions(resp) {
+ const hint = resp.headers.get('x-da-child-actions') ?? resp.headers.get('x-da-actions');
+ if (hint) resp.permissions = hint.split('=').pop().split(',');
+ else resp.permissions = ['read', 'write'];
+ return resp;
+}
+
+export async function checkDoc(sourceUrl) {
+ const resp = await daFetch(sourceUrl, { method: 'HEAD' });
+ return parsePermissions(resp);
+}
diff --git a/blocks/canvas/ew-editor-doc/utils/teardown.js b/blocks/canvas/ew-editor-doc/utils/teardown.js
new file mode 100644
index 00000000..a066b17c
--- /dev/null
+++ b/blocks/canvas/ew-editor-doc/utils/teardown.js
@@ -0,0 +1,23 @@
+export function teardownEditorDocResources({
+ clearPortHandler,
+ awarenessOff,
+ wsProvider,
+ view,
+ proseEl,
+ onCollabUsersCleared,
+}) {
+ clearPortHandler();
+ if (awarenessOff) {
+ awarenessOff();
+ }
+ if (wsProvider) {
+ wsProvider.disconnect({ data: 'unmount' });
+ }
+ if (view) {
+ view.destroy();
+ }
+ if (proseEl?.parentNode) {
+ proseEl.remove();
+ }
+ onCollabUsersCleared();
+}
diff --git a/blocks/canvas/ew-editor-split/ew-editor-split.css b/blocks/canvas/ew-editor-split/ew-editor-split.css
new file mode 100644
index 00000000..1fc9a2aa
--- /dev/null
+++ b/blocks/canvas/ew-editor-split/ew-editor-split.css
@@ -0,0 +1,43 @@
+.nx-canvas-editor-mount.nx-canvas-editor-mount-split {
+ flex-flow: row nowrap;
+ align-items: stretch;
+
+ --nx-canvas-split-ratio: 0.5;
+}
+
+.nx-canvas-split-gutter {
+ display: none;
+ flex: 0 0 2px;
+ width: 2px;
+ min-width: 2px;
+ box-sizing: border-box;
+ flex-shrink: 0;
+ background-color: var(--s2-gray-200);
+ cursor: col-resize;
+ touch-action: none;
+ user-select: none;
+}
+
+.nx-canvas-editor-mount-split .nx-canvas-split-gutter {
+ display: block;
+}
+
+/* Split: preview left, gutter, doc right; ratio = left / (100% − 2px) */
+.nx-canvas-editor-mount-split ew-editor-wysiwyg {
+ --nx-canvas-wysiwyg-split-width: calc((100% - 2px) * var(--nx-canvas-split-ratio, 0.5));
+
+ flex: 0 0 var(--nx-canvas-wysiwyg-split-width);
+ width: var(--nx-canvas-wysiwyg-split-width);
+ min-width: var(--nx-canvas-wysiwyg-split-width);
+ max-width: none;
+ flex-shrink: 0;
+ max-height: none;
+}
+
+.nx-canvas-editor-mount-split ew-editor-doc {
+ flex: 1 1 0;
+ min-width: 0;
+ width: auto;
+ max-width: none;
+ max-height: none;
+}
diff --git a/blocks/canvas/ew-editor-split/ew-editor-split.js b/blocks/canvas/ew-editor-split/ew-editor-split.js
new file mode 100644
index 00000000..c9b49b45
--- /dev/null
+++ b/blocks/canvas/ew-editor-split/ew-editor-split.js
@@ -0,0 +1,121 @@
+import { getNx } from '../../../scripts/utils.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+document.adoptedStyleSheets = [...document.adoptedStyleSheets, style];
+
+export const SPLIT_RATIO_STORAGE_KEY = 'nx-canvas-split-ratio';
+
+const SPLIT_RATIO_MIN = 0.15;
+const SPLIT_RATIO_MAX = 0.85;
+const SPLIT_GUTTER_PX = 2;
+
+const SPLIT_MOUNT_CLASS = 'nx-canvas-editor-mount-split';
+const SPLIT_GUTTER_CLASS = 'nx-canvas-split-gutter';
+
+function readPersistedSplitRatio() {
+ try {
+ const v = Number.parseFloat(sessionStorage.getItem(SPLIT_RATIO_STORAGE_KEY), 10);
+ if (Number.isFinite(v) && v >= SPLIT_RATIO_MIN && v <= SPLIT_RATIO_MAX) return v;
+ } catch {
+ /* ignore */
+ }
+ return 0.5;
+}
+
+function persistSplitRatio(ratio) {
+ try {
+ sessionStorage.setItem(SPLIT_RATIO_STORAGE_KEY, String(ratio));
+ } catch {
+ /* ignore */
+ }
+}
+
+function clampSplitRatio(ratio) {
+ return Math.min(SPLIT_RATIO_MAX, Math.max(SPLIT_RATIO_MIN, ratio));
+}
+
+/** Toggle split row on the mount; seed `--nx-canvas-split-ratio` when entering split mode. */
+export function syncEditorSplitLayout({ mountRoot, view }) {
+ mountRoot.classList.toggle(SPLIT_MOUNT_CLASS, view === 'split');
+ if (view !== 'split') return;
+ const cur = mountRoot.style.getPropertyValue('--nx-canvas-split-ratio').trim();
+ if (!cur) {
+ mountRoot.style.setProperty('--nx-canvas-split-ratio', String(readPersistedSplitRatio()));
+ }
+}
+
+export function removeSplitGutter(mountRoot) {
+ mountRoot.querySelector(`.${SPLIT_GUTTER_CLASS}`)?.remove();
+}
+
+function ensureSplitGutter(mountRoot) {
+ let g = mountRoot.querySelector(`.${SPLIT_GUTTER_CLASS}`);
+ if (!g) {
+ g = document.createElement('div');
+ g.className = SPLIT_GUTTER_CLASS;
+ g.setAttribute('role', 'separator');
+ g.setAttribute('aria-orientation', 'vertical');
+ g.setAttribute('aria-label', 'Resize split between preview and editor');
+ g.tabIndex = -1;
+ mountRoot.append(g);
+ }
+ return g;
+}
+
+/** WYSIWYG (left), 2px gutter, doc (right) — safe if other nodes exist in the mount. */
+export function finalizeSplitEditorMountOrder(mountRoot) {
+ const doc = mountRoot.querySelector('ew-editor-doc');
+ const wyg = mountRoot.querySelector('ew-editor-wysiwyg');
+ if (!doc || !wyg) return;
+ const g = ensureSplitGutter(mountRoot);
+ mountRoot.append(wyg);
+ mountRoot.append(g);
+ mountRoot.append(doc);
+}
+
+function splitRatioFromPointer(mountRoot, clientX) {
+ const rect = mountRoot.getBoundingClientRect();
+ const inner = rect.width - SPLIT_GUTTER_PX;
+ if (inner <= 0) return 0.5;
+ return clampSplitRatio((clientX - rect.left) / inner);
+}
+
+/** Pointer-drag on the split gutter; persists ratio under {@link SPLIT_RATIO_STORAGE_KEY}. */
+export function installEditorSplitDrag(mountRoot) {
+ if (mountRoot.dataset.nxSplitDragInstalled) return;
+ mountRoot.dataset.nxSplitDragInstalled = '1';
+
+ mountRoot.addEventListener('pointerdown', (e) => {
+ if (!mountRoot.classList.contains(SPLIT_MOUNT_CLASS)) return;
+ const gutter = e.target?.closest?.(`.${SPLIT_GUTTER_CLASS}`);
+ if (!gutter || !mountRoot.contains(gutter)) return;
+ e.preventDefault();
+ gutter.setPointerCapture(e.pointerId);
+
+ const onMove = (ev) => {
+ const ratio = splitRatioFromPointer(mountRoot, ev.clientX);
+ mountRoot.style.setProperty('--nx-canvas-split-ratio', String(ratio));
+ };
+
+ const onUp = () => {
+ try {
+ gutter.releasePointerCapture(e.pointerId);
+ } catch {
+ /* ignore if already released */
+ }
+ window.removeEventListener('pointermove', onMove);
+ window.removeEventListener('pointerup', onUp);
+ window.removeEventListener('pointercancel', onUp);
+ const raw = mountRoot.style.getPropertyValue('--nx-canvas-split-ratio').trim();
+ const ratio = clampSplitRatio(Number.parseFloat(raw, 10) || readPersistedSplitRatio());
+ mountRoot.style.setProperty('--nx-canvas-split-ratio', String(ratio));
+ persistSplitRatio(ratio);
+ };
+
+ window.addEventListener('pointermove', onMove);
+ window.addEventListener('pointerup', onUp);
+ window.addEventListener('pointercancel', onUp);
+ });
+}
diff --git a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css
new file mode 100644
index 00000000..9369524c
--- /dev/null
+++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css
@@ -0,0 +1,37 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.ew-editor-wysiwyg-surface {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+}
+
+.ew-editor-wysiwyg-surface[hidden] {
+ display: none !important;
+}
+
+.ew-editor-wysiwyg-iframe {
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+ border: 0;
+}
+
+.ew-editor-wysiwyg-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ min-height: 120px;
+ padding: 1rem;
+ color: light-dark(#555, #aaa);
+ font: var(--nx-font-body, 14px/1.4 system-ui, sans-serif);
+ text-align: center;
+}
diff --git a/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js
new file mode 100644
index 00000000..54d6811c
--- /dev/null
+++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js
@@ -0,0 +1,233 @@
+import { LitElement, html } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { getPreviewOrigin, fetchWysiwygCookie } from '../editor-utils/editor-utils.js';
+import { initIms as loadIms } from '../../shared/utils.js';
+import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+const QUICK_EDIT_INIT_INTERVAL_MS = 400;
+const QUICK_EDIT_INIT_MAX_ATTEMPTS = 25;
+
+const WYSIWYG_PORT_READY_ATTR = 'data-nx-wysiwyg-port-ready';
+
+function buildQuickEditInitPayload({ org, repo, path }) {
+ const pathWithoutOrgRepo = path.split('/').slice(2).join('/');
+ const pathname = pathWithoutOrgRepo ? `/${pathWithoutOrgRepo}` : '/';
+ return {
+ config: { mountpoint: `${getPreviewOrigin(org, repo)}/${org}/${repo}` },
+ location: { pathname },
+ };
+}
+
+async function tryLoadWysiwygPreviewCookies({ org, repo, path, getCurrentCtx }) {
+ try {
+ const token = (await loadIms())?.accessToken?.token;
+ if (!token) {
+ // eslint-disable-next-line no-console
+ console.warn('[ew-editor-wysiwyg] Preview cookies: no auth token, proceeding without cookies');
+ } else {
+ await fetchWysiwygCookie({ org, repo, token }).catch((e) => {
+ // eslint-disable-next-line no-console
+ console.warn('[ew-editor-wysiwyg] Preview cookies failed, proceeding without cookies', e);
+ });
+ }
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn('[ew-editor-wysiwyg] Preview cookie setup failed, proceeding without cookies', e);
+ }
+ const cur = getCurrentCtx();
+ return cur?.org === org && cur?.repo === repo && cur?.path === path;
+}
+
+export class EwEditorWysiwyg extends LitElement {
+ static properties = {
+ ctx: { type: Object },
+ _cookieReady: { state: true },
+ _loading: { state: true },
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this._onCanvasEditorActive = (e) => {
+ this._canvasActiveView = e.detail?.view;
+ this._syncCanvasVisibility();
+ };
+ this.parentElement?.addEventListener('nx-canvas-editor-active', this._onCanvasEditorActive);
+ this._syncCanvasVisibility();
+ }
+
+ disconnectedCallback() {
+ this.parentElement?.removeEventListener('nx-canvas-editor-active', this._onCanvasEditorActive);
+ this._clearQuickEditRetry();
+ super.disconnectedCallback();
+ }
+
+ get _iframeSrc() {
+ const { org, repo, path } = this.ctx ?? {};
+ if (!org || !repo || !path || !this._cookieReady) return null;
+ const segments = path.split('/');
+ const pathWithoutOrgRepo = segments.slice(2).join('/');
+ const encodedPath = pathWithoutOrgRepo.split('/').map(encodeURIComponent).join('/');
+ const quickEdit = new URLSearchParams(window.location.search).get('quick-edit') || 'ew';
+ const base = `${getPreviewOrigin(org, repo)}/${encodedPath}?nx=ew&quick-edit=${encodeURIComponent(quickEdit)}`;
+ return `${base}&controller=parent`;
+ }
+
+ _disposeQuickEditLocalPort() {
+ if (!this._quickEditLocalPort) return;
+ try {
+ this._quickEditLocalPort.onmessage = null;
+ this._quickEditLocalPort.close();
+ } catch {
+ /* ignore */
+ }
+ this._quickEditLocalPort = null;
+ }
+
+ _clearQuickEditRetry() {
+ if (this._quickEditInitRetryId) {
+ clearInterval(this._quickEditInitRetryId);
+ this._quickEditInitRetryId = null;
+ }
+ this._disposeQuickEditLocalPort();
+ }
+
+ _syncCanvasVisibility() {
+ const view = this._canvasActiveView ?? 'layout';
+ const portReady = this.hasAttribute(WYSIWYG_PORT_READY_ATTR);
+ const showWysiwyg = view === 'layout' || view === 'split';
+ this.hidden = !showWysiwyg;
+ this._loading = showWysiwyg && !portReady;
+ hideSelectionToolbar();
+ }
+
+ _resetCookieStateForCtxChange() {
+ this._clearQuickEditRetry();
+ this._cookieReady = false;
+ }
+
+ updated(changed) {
+ super.updated(changed);
+ if (!changed.has('ctx')) return;
+ this.removeAttribute(WYSIWYG_PORT_READY_ATTR);
+ this._resetCookieStateForCtxChange();
+ this._syncCanvasVisibility();
+ const { org, repo, path } = this.ctx ?? {};
+ if (!org || !repo || !path) return;
+
+ tryLoadWysiwygPreviewCookies({
+ org,
+ repo,
+ path,
+ getCurrentCtx: () => this.ctx,
+ }).then((ok) => {
+ if (!ok) return;
+ this._cookieReady = true;
+ this.requestUpdate();
+ });
+ }
+
+ _dispatchWysiwygPortReady(port) {
+ this._clearQuickEditRetry();
+ this.setAttribute(WYSIWYG_PORT_READY_ATTR, '');
+ this._syncCanvasVisibility();
+ const iframe = this.shadowRoot?.querySelector('iframe');
+ this.dispatchEvent(new CustomEvent('nx-wysiwyg-port-ready', {
+ bubbles: true,
+ composed: true,
+ detail: { port, iframe },
+ }));
+ }
+
+ _scheduleQuickEditInitRetries(send) {
+ let attempts = 0;
+ this._quickEditInitRetryId = setInterval(() => {
+ attempts += 1;
+ if (attempts >= QUICK_EDIT_INIT_MAX_ATTEMPTS) {
+ this._clearQuickEditRetry();
+ return;
+ }
+ send();
+ }, QUICK_EDIT_INIT_INTERVAL_MS);
+ }
+
+ _postQuickEditInitToIframe({ iframe, config, location, onReady }) {
+ this._disposeQuickEditLocalPort();
+ const { port1, port2 } = new MessageChannel();
+ this._quickEditLocalPort = port1;
+ port1.onmessage = (ev) => {
+ if (ev.data?.ready !== true) return;
+ this._quickEditLocalPort = null;
+ onReady(port1);
+ };
+ try {
+ const targetOrigin = new URL(iframe.src).origin;
+ iframe.contentWindow.postMessage({ init: config, location }, targetOrigin, [port2]);
+ } catch (err) {
+ this._disposeQuickEditLocalPort();
+ // eslint-disable-next-line no-console
+ console.error('[ew-editor-wysiwyg] Error posting init to iframe', err);
+ }
+ }
+
+ _onIframeLoad(e) {
+ const iframe = e?.target;
+ const { org, repo, path } = this.ctx ?? {};
+ if (!iframe?.contentWindow || !org || !repo || !path) return;
+
+ this.removeAttribute(WYSIWYG_PORT_READY_ATTR);
+ this._clearQuickEditRetry();
+ this._syncCanvasVisibility();
+
+ const { config, location } = buildQuickEditInitPayload({ org, repo, path });
+ const send = () => this._postQuickEditInitToIframe({
+ iframe,
+ config,
+ location,
+ onReady: (port) => this._dispatchWysiwygPortReady(port),
+ });
+
+ send();
+ this._scheduleQuickEditInitRetries(send);
+ }
+
+ _onIframeBlur() {
+ hideSelectionToolbar();
+ }
+
+ render() {
+ const { org, repo, path } = this.ctx ?? {};
+ const hasPath = org && repo && path;
+ let body;
+ if (!hasPath) {
+ body = html`
+
Select an HTML file for WYSIWYG preview.
+ `;
+ } else if (!this._cookieReady) {
+ body = html`
Loading preview…
`;
+ } else {
+ const src = this._iframeSrc;
+ body = html`
+
+ `;
+ }
+ return html`
+
+ ${body}
+
+ `;
+ }
+}
+
+customElements.define('ew-editor-wysiwyg', EwEditorWysiwyg);
diff --git a/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js b/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js
new file mode 100644
index 00000000..c8804fe8
--- /dev/null
+++ b/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js
@@ -0,0 +1,29 @@
+import { updateDocument, updateState, getEditor } from '../editor-utils/editor-utils.js';
+import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js';
+import { handleImageReplace } from './utils/image.js';
+import {
+ handleCursorMove,
+ handleUndoRedo,
+ handleIframeSelectionChange,
+} from './utils/handlers.js';
+
+export function createControllerOnMessage(ctx) {
+ return function onMessage(e) {
+ if (e.data.type === 'cursor-move') {
+ hideSelectionToolbar();
+ handleCursorMove(e.data, ctx);
+ } else if (e.data.type === 'reload') {
+ updateDocument(ctx);
+ } else if (e.data.type === 'image-replace') {
+ handleImageReplace(e.data, ctx);
+ } else if (e.data.type === 'get-editor') {
+ getEditor(e.data, ctx);
+ } else if (e.data.type === 'node-update') {
+ updateState(e.data, ctx);
+ } else if (e.data.type === 'history') {
+ handleUndoRedo(e.data, ctx);
+ } else if (e.data.type === 'selection-change') {
+ handleIframeSelectionChange(e.data, ctx);
+ }
+ };
+}
diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js
new file mode 100644
index 00000000..bcbc4877
--- /dev/null
+++ b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js
@@ -0,0 +1,161 @@
+import { TextSelection, yUndo, yRedo } from 'da-y-wrapper';
+import {
+ getSelectionToolbar,
+ hideSelectionToolbar,
+ TOOLBAR_PADDING_GAP,
+ NX_QUICK_EDIT_IFRAME_SELECTION_META,
+ NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META,
+} from '../../editor-utils/selection-toolbar.js';
+import { editorSelectChange } from '../../editor-utils/editor-utils.js';
+import { getActiveBlockIndex } from '../../editor-utils/blocks.js';
+
+export function handleCursorMove({ cursorOffset, textCursorOffset }, ctx) {
+ const { view, wsProvider } = ctx;
+ if (!view || !wsProvider) return;
+
+ if (cursorOffset == null || textCursorOffset == null) {
+ delete view.hasFocus;
+ wsProvider.awareness.setLocalStateField('cursor', null);
+ return;
+ }
+
+ const { state } = view;
+ const position = cursorOffset + textCursorOffset;
+
+ try {
+ if (position < 0 || position > state.doc.content.size) {
+ // eslint-disable-next-line no-console
+ console.warn('Invalid cursor position:', position);
+ return;
+ }
+
+ view.hasFocus = () => true;
+
+ const { tr } = state;
+ tr.setSelection(TextSelection.create(state.doc, position));
+
+ // Sync stored marks so the toolbar reflects the marks active at the cursor.
+ // Two problems this solves:
+ // 1. ProseMirror clears storedMarks whenever selection.anchor changes, which
+ // happens on every cursor-move — that wipes toolbar-toggled marks before the
+ // first keystroke arrives.
+ // 2. marksAcross() returns Mark.none when the cursor is at the end of a mark
+ // run (nothing to the right), so the toolbar shows the mark as inactive even
+ // though the text is marked. nodeBefore/nodeAfter covers both sides.
+ const $pos = state.doc.resolve(position);
+ const marksBefore = $pos.nodeBefore?.marks;
+ const marksAfter = $pos.nodeAfter?.marks;
+ const marksAtCursor = (marksBefore?.length ? marksBefore : null)
+ ?? (marksAfter?.length ? marksAfter : null);
+
+ if (marksAtCursor) {
+ // Cursor is adjacent to marked text — use those marks (handles Cmd+B case).
+ tr.setStoredMarks(marksAtCursor);
+ } else if (state.storedMarks?.length) {
+ // No marked text at this position, but user explicitly toggled a mark via
+ // the toolbar — preserve it so it survives cursor-move events before typing.
+ tr.setStoredMarks(state.storedMarks);
+ }
+
+ ctx.suppressRerender = true;
+ view.dispatch(tr.scrollIntoView());
+ ctx.suppressRerender = false;
+ const blockIndex = getActiveBlockIndex(view);
+ if (blockIndex !== ctx.lastBlockIndex) {
+ ctx.lastBlockIndex = blockIndex;
+ editorSelectChange.emit({ blockIndex, source: 'wysiwyg' });
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error moving cursor:', error);
+ }
+}
+
+export function handleUndoRedo(data, ctx) {
+ const { action } = data;
+ const view = ctx?.view;
+ if (!view) return;
+ if (action === 'undo') {
+ yUndo(view.state);
+ } else if (action === 'redo') {
+ yRedo(view.state);
+ }
+}
+
+export function handleStoredMarks({ marks }, ctx) {
+ const { view } = ctx;
+ if (!view) return;
+ const { state } = view;
+ const { schema } = state;
+ try {
+ const parsedMarks = marks
+ .map((m) => {
+ const markType = schema.marks[m.type];
+ return markType ? markType.create(m.attrs) : null;
+ })
+ .filter(Boolean);
+ const { tr } = state;
+ tr.setStoredMarks(parsedMarks);
+ ctx.suppressRerender = true;
+ view.dispatch(tr);
+ ctx.suppressRerender = false;
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[quick-edit-controller] handleStoredMarks failed', e?.message);
+ }
+}
+
+export function handleSelectionChange({ anchor, head }, ctx, { fromQuickEditIframe = false } = {}) {
+ const { view } = ctx;
+ if (!view) return false;
+ const { state } = view;
+ try {
+ const a = Math.max(0, Math.min(anchor, state.doc.content.size));
+ const h = Math.max(0, Math.min(head, state.doc.content.size));
+ const { tr } = state;
+ tr.setSelection(TextSelection.create(state.doc, a, h));
+ if (fromQuickEditIframe) tr.setMeta(NX_QUICK_EDIT_IFRAME_SELECTION_META, true);
+ ctx.suppressRerender = true;
+ view.dispatch(tr);
+ ctx.suppressRerender = false;
+ return true;
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error('[quick-edit-controller] handleSelectionChange failed', e?.message);
+ return false;
+ }
+}
+
+function positionSelectionToolbarFromIframe(data, ctx) {
+ const { view } = ctx;
+ const { anchorX, anchorY } = data;
+ const { iframe } = ctx;
+ if (!iframe) return;
+
+ const iframeRect = iframe.getBoundingClientRect();
+ const x = iframeRect.left + anchorX;
+ const y = iframeRect.top + anchorY - TOOLBAR_PADDING_GAP;
+ const tb = getSelectionToolbar();
+ tb.view = view;
+ tb.show({ x, y });
+}
+
+/** PostMessage `selection-change` from wysiwyg iframe: sync PM selection and toolbar. */
+export function handleIframeSelectionChange(data, ctx) {
+ const { anchor, head } = data;
+ if (anchor === head) {
+ hideSelectionToolbar();
+ const { view } = ctx;
+ if (view) {
+ const tr = view.state.tr
+ .setMeta(NX_QUICK_EDIT_CLEAR_IFRAME_SELECTION_ORIGIN_META, true)
+ .setMeta('addToHistory', false);
+ ctx.suppressRerender = true;
+ view.dispatch(tr);
+ ctx.suppressRerender = false;
+ }
+ return;
+ }
+ if (!handleSelectionChange(data, ctx, { fromQuickEditIframe: true })) return;
+ positionSelectionToolbarFromIframe(data, ctx);
+}
diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js
new file mode 100644
index 00000000..cc392222
--- /dev/null
+++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js
@@ -0,0 +1,124 @@
+import { getNx } from '../../../../scripts/utils.js';
+
+const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`);
+
+function updateImageInDocument(view, originalSrc, newSrc) {
+ if (!view) return false;
+
+ const { state } = view;
+ const { tr } = state;
+ let updated = false;
+
+ state.doc.descendants((node, pos) => {
+ if (node.type.name === 'image') {
+ const currentSrc = node.attrs.src;
+ let isMatch = currentSrc === originalSrc;
+
+ if (!isMatch) {
+ try {
+ const currentUrl = new URL(currentSrc, window.location.href);
+ const originalUrl = new URL(originalSrc, window.location.href);
+ isMatch = currentUrl.pathname === originalUrl.pathname;
+ } catch {
+ isMatch = currentSrc.includes(originalSrc) || originalSrc.includes(currentSrc);
+ }
+ }
+
+ if (isMatch) {
+ const newAttrs = { ...node.attrs, src: newSrc };
+ tr.setNodeMarkup(pos, null, newAttrs);
+ updated = true;
+ }
+ }
+ });
+
+ if (updated) {
+ view.dispatch(tr);
+ }
+
+ return updated;
+}
+
+function dataUrlToBlob(dataUrl) {
+ const [header, base64Data] = dataUrl.split(',');
+ const mimeMatch = header.match(/:(.*?);/);
+ const mimeType = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
+ const byteString = atob(base64Data);
+ const arrayBuffer = new ArrayBuffer(byteString.length);
+ const uint8Array = new Uint8Array(arrayBuffer);
+ for (let i = 0; i < byteString.length; i += 1) {
+ uint8Array[i] = byteString.charCodeAt(i);
+ }
+ return new Blob([uint8Array], { type: mimeType });
+}
+
+function getPageName(currentPath) {
+ if (currentPath.endsWith('/')) return `${currentPath.replace(/^\//, '')}index`;
+ return currentPath.replace(/^\//, '');
+}
+
+export async function handleImageReplace({ imageData, fileName, originalSrc }, ctx) {
+ ctx.suppressRerender = true;
+
+ try {
+ // eslint-disable-next-line no-console
+ console.log('handleImageReplace', fileName, originalSrc);
+
+ const blob = dataUrlToBlob(imageData);
+
+ const pageName = getPageName(ctx.path);
+ const parentPath = ctx.path === '/' ? '' : ctx.path.replace(/\/[^/]+$/, '');
+
+ // Same upload path and URL as da-nx quick-edit-portal/src/images.js
+ const uploadPath = `${parentPath}/.${pageName}/${fileName}`;
+ const uploadUrl = `${DA_ADMIN}/source/${ctx.owner}/${ctx.repo}${uploadPath}`;
+
+ const tokenPromise = typeof ctx.getToken === 'function' ? ctx.getToken() : null;
+ const token = tokenPromise != null && typeof tokenPromise?.then === 'function'
+ ? await tokenPromise
+ : tokenPromise;
+ const headers = {};
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ const formData = new FormData();
+ formData.append('data', blob, fileName);
+
+ const resp = await fetch(uploadUrl, {
+ method: 'PUT',
+ body: formData,
+ headers,
+ });
+
+ if (!resp.ok) {
+ ctx.port.postMessage({
+ type: 'image-error',
+ error: `Upload failed with status ${resp.status}`,
+ originalSrc,
+ });
+ return;
+ }
+
+ // Same as da-nx: AEM delivery URL for the uploaded image
+ const newSrc = `${DA_CONTENT}/${ctx.owner}/${ctx.repo}${uploadPath}`;
+
+ updateImageInDocument(ctx.view, originalSrc, newSrc);
+
+ ctx.port.postMessage({
+ type: 'update-image-src',
+ newSrc,
+ originalSrc,
+ });
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error replacing image:', error);
+ ctx.port.postMessage({
+ type: 'image-error',
+ error: error.message,
+ originalSrc,
+ });
+ } finally {
+ setTimeout(() => {
+ ctx.suppressRerender = false;
+ }, 500);
+ }
+}
diff --git a/blocks/canvas/ew-file-explorer/ew-file-explorer.css b/blocks/canvas/ew-file-explorer/ew-file-explorer.css
new file mode 100644
index 00000000..0c5f6887
--- /dev/null
+++ b/blocks/canvas/ew-file-explorer/ew-file-explorer.css
@@ -0,0 +1,106 @@
+:host {
+ display: block;
+ height: 100%;
+ min-height: 0;
+ font-family: var(--s2-font-family);
+}
+
+.ew-file-explorer {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.tree {
+ flex: 1;
+ overflow-y: auto;
+ list-style: none;
+ margin: 0;
+ padding: var(--s2-spacing-75) 0;
+}
+
+[role="group"] {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ min-height: 32px;
+ padding: var(--s2-spacing-75) var(--s2-spacing-100);
+ padding-inline-start: calc(var(--depth, 0) * var(--s2-spacing-300) + var(--s2-spacing-100));
+ border: none;
+ background: transparent;
+ color: var(--s2-gray-800);
+ font-family: inherit;
+ font-size: var(--s2-body-size-s);
+ font-weight: 500;
+ text-align: start;
+ cursor: pointer;
+ gap: var(--s2-spacing-75);
+ box-sizing: border-box;
+
+ &:hover {
+ background: var(--s2-gray-75);
+ }
+
+ &.selected {
+ background: var(--s2-blue-100);
+ color: var(--s2-blue-900);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--s2-blue-700);
+ outline-offset: -2px;
+ border-radius: var(--s2-corner-radius-75);
+ }
+}
+
+.row[aria-expanded]::before {
+ content: '›';
+ display: inline-block;
+ flex-shrink: 0;
+ width: 1rem;
+ text-align: center;
+ transition: transform 0.15s;
+}
+
+.row[aria-expanded="true"]::before {
+ transform: rotate(90deg);
+}
+
+/* Files have no chevron — indent to align with folder labels */
+.row.file {
+ padding-inline-start: calc(var(--depth, 0) * var(--s2-spacing-300) + var(--s2-spacing-100) + 1rem + var(--s2-spacing-75));
+}
+
+.label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.notice {
+ padding: var(--s2-spacing-75) var(--s2-spacing-100);
+ font-size: var(--s2-body-size-s);
+ color: var(--s2-gray-600);
+ margin: 0;
+
+ &.error {
+ color: var(--s2-red-700);
+ }
+}
+
+.placeholder {
+ padding: var(--s2-spacing-300) var(--s2-spacing-100);
+ font-size: var(--s2-body-size-s);
+ color: var(--s2-gray-600);
+ text-align: center;
+ margin: 0;
+}
diff --git a/blocks/canvas/ew-file-explorer/ew-file-explorer.js b/blocks/canvas/ew-file-explorer/ew-file-explorer.js
new file mode 100644
index 00000000..87dad725
--- /dev/null
+++ b/blocks/canvas/ew-file-explorer/ew-file-explorer.js
@@ -0,0 +1,184 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { listFolder, itemHashPath } from '../../shared/daFiles.js';
+import { treeKeydown, treeFocusIn, treeEnsureTabStop } from '../utils/tree-nav.js';
+
+const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+function listItemToNode(item, cache) {
+ const pathKey = (item.path || '').replace(/^\//, '');
+ const fullpath = `/${pathKey}`;
+ const isDir = !item.ext;
+ return {
+ name: item.name,
+ type: isDir ? 'directory' : 'file',
+ path: fullpath,
+ pathKey,
+ ext: item.ext,
+ children: isDir && cache[fullpath]
+ ? cache[fullpath].map((child) => listItemToNode(child, cache))
+ : [],
+ };
+}
+
+function buildTree(cache, rootFullpath) {
+ const pathKey = rootFullpath.replace(/^\//, '');
+ const items = cache[rootFullpath];
+ return [{
+ name: pathKey.split('/').pop(),
+ type: 'directory',
+ path: rootFullpath,
+ pathKey,
+ children: items ? items.map((item) => listItemToNode(item, cache)) : [],
+ }];
+}
+
+class EwFileExplorer extends LitElement {
+ static properties = {
+ _cache: { state: true },
+ _loading: { state: true },
+ _error: { state: true },
+ _expanded: { state: true },
+ _selectedPath: { state: true },
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this._unsubHash = hashChange.subscribe((state) => this._onHashChange(state));
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._unsubHash?.();
+ }
+
+ updated() {
+ treeEnsureTabStop(this.shadowRoot);
+ }
+
+ _onHashChange({ org, site, path }) {
+ const rootChanged = org !== this._org || site !== this._site;
+ this._org = org;
+ this._site = site;
+
+ if (!org || !site) {
+ this._cache = {};
+ this._expanded = new Set();
+ this._selectedPath = undefined;
+ this._error = null;
+ return;
+ }
+
+ this._selectedPath = path ? `${org}/${site}/${path}` : undefined;
+
+ if (rootChanged) {
+ this._cache = {};
+ this._expanded = new Set([`${org}/${site}`]);
+ this._loadFromRoot(`/${org}/${site}`, org, site, path);
+ }
+ }
+
+ async _loadFromRoot(rootFullpath, org, site, path) {
+ this._loading = true;
+ this._error = null;
+ const cache = {};
+ const orgSite = `${org}/${site}`;
+ const expanded = new Set([orgSite]);
+ const toFetch = [rootFullpath];
+
+ if (path) {
+ const parts = path.split('/');
+ for (let i = 1; i < parts.length; i += 1) {
+ const ancestorPath = `/${orgSite}/${parts.slice(0, i).join('/')}`;
+ toFetch.push(ancestorPath);
+ expanded.add(ancestorPath.replace(/^\//, ''));
+ }
+ }
+
+ try {
+ await Promise.all(toFetch.map(async (fp) => {
+ const result = await listFolder(fp);
+ if (Array.isArray(result)) cache[fp] = result;
+ else if (fp === rootFullpath) this._error = result.error;
+ }));
+ this._cache = cache;
+ this._expanded = expanded;
+ } finally {
+ this._loading = false;
+ }
+ }
+
+ async _loadAndExpand(pathKey) {
+ this._loading = true;
+ const result = await listFolder(`/${pathKey}`);
+ if (!Array.isArray(result)) {
+ this._error = result.error;
+ } else {
+ this._cache = { ...this._cache, [`/${pathKey}`]: result };
+ this._expanded = new Set([...(this._expanded ?? []), pathKey]);
+ }
+ this._loading = false;
+ }
+
+ _toggle(pathKey, path) {
+ if (!this._cache?.[path]) {
+ this._loadAndExpand(pathKey);
+ return;
+ }
+ const next = new Set(this._expanded);
+ if (next.has(pathKey)) next.delete(pathKey);
+ else next.add(pathKey);
+ this._expanded = next;
+ }
+
+ _renderNode(item, depth) {
+ const { type, pathKey, name, children, path } = item;
+ const isDir = type === 'directory';
+ const expanded = isDir && this._expanded?.has(pathKey);
+ const hashPath = itemHashPath(item);
+ const selected = this._selectedPath === hashPath;
+
+ return html`
+
+
+ ${expanded && children.length ? html`
+
+ ${children.map((c) => this._renderNode(c, depth + 1))}
+
` : nothing}
+ `;
+ }
+
+ render() {
+ if (!this._org || !this._site) {
+ return html`
+
Select a site to browse files.
+
`;
+ }
+
+ const tree = buildTree(this._cache ?? {}, `/${this._org}/${this._site}`);
+
+ return html`
+ ${this._error ? html`
${this._error}
` : nothing}
+ ${this._loading && !Object.keys(this._cache ?? {}).length
+ ? html`
Loading…
` : nothing}
+
treeKeydown(e, this.shadowRoot)}"
+ @focusin="${(e) => treeFocusIn(e, this.shadowRoot)}">
+ ${tree.map((item) => this._renderNode(item, 0))}
+
+
`;
+ }
+}
+
+customElements.define('ew-file-explorer', EwFileExplorer);
diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.css b/blocks/canvas/ew-page-outline/ew-page-outline.css
new file mode 100644
index 00000000..650bff26
--- /dev/null
+++ b/blocks/canvas/ew-page-outline/ew-page-outline.css
@@ -0,0 +1,135 @@
+:host {
+ display: block;
+ height: 100%;
+ min-height: 0;
+ font-family: var(--s2-font-family);
+}
+
+.ew-page-outline {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ margin: 0;
+}
+
+.list-wrap {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--s2-spacing-75) 0;
+}
+
+.outline-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.outline-section {
+ margin: 0;
+ padding: 0;
+ border-bottom: 1px solid var(--s2-gray-200);
+
+ &:last-child:not([data-drop-position="after"]) {
+ border-bottom: none;
+ }
+}
+
+.section-header {
+ background: var(--s2-gray-75);
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
+
+.section-label {
+ display: block;
+ padding: var(--s2-spacing-200) var(--s2-spacing-100);
+ font-size: var(--s2-body-size-s);
+ font-weight: 600;
+ color: var(--s2-gray-800);
+}
+
+.block-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.block-item {
+ display: block;
+ padding: var(--s2-spacing-75) var(--s2-spacing-100);
+ padding-inline-start: var(--s2-spacing-300);
+ cursor: grab;
+ font-size: var(--s2-body-size-s);
+ font-weight: 500;
+ color: var(--s2-gray-800);
+ min-height: 32px;
+ box-sizing: border-box;
+ border-bottom: 1px solid var(--s2-gray-100);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:hover {
+ background: var(--s2-gray-75);
+ }
+
+ &:focus-visible {
+ background: var(--s2-gray-75);
+ }
+
+ &:last-child:not([data-drop-position="after"]) {
+ border-bottom: none;
+ }
+
+ &[aria-selected="true"] {
+ background: var(--s2-blue-100);
+ color: var(--s2-blue-900);
+ }
+}
+
+[role="treeitem"]:focus-visible {
+ outline: 2px solid var(--s2-blue-700);
+ outline-offset: -2px;
+ border-radius: var(--s2-corner-radius-75);
+}
+
+.dragging {
+ opacity: 0.4;
+ cursor: grabbing;
+}
+
+[data-drop-position="before"] {
+ border-top: 2px solid var(--s2-blue-600, #147af3);
+}
+
+[data-drop-position="after"] {
+ border-bottom: 2px solid var(--s2-blue-600, #147af3);
+}
+
+.block-empty {
+ cursor: default;
+}
+
+.empty-label {
+ font-style: italic;
+ color: var(--s2-gray-600);
+ font-weight: 400;
+}
+
+.placeholder {
+ padding: var(--s2-spacing-300) var(--s2-spacing-100);
+ font-size: var(--s2-body-size-s);
+ color: var(--s2-gray-600);
+ text-align: center;
+
+ code {
+ font-family: ui-monospace, monospace;
+ background: var(--s2-gray-100);
+ padding: 0.1em 0.3em;
+ border-radius: var(--s2-corner-radius-75);
+ }
+}
diff --git a/blocks/canvas/ew-page-outline/ew-page-outline.js b/blocks/canvas/ew-page-outline/ew-page-outline.js
new file mode 100644
index 00000000..8753e398
--- /dev/null
+++ b/blocks/canvas/ew-page-outline/ew-page-outline.js
@@ -0,0 +1,230 @@
+import { LitElement, html } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { treeKeydown } from '../utils/tree-nav.js';
+import { editorHtmlChange, editorSelectChange, parseSections } from '../editor-utils/editor-utils.js';
+import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js';
+import { moveBlock, moveSection } from '../editor-utils/blocks.js';
+
+const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+const OUTLINE_TYPES = {
+ SECTION: 'section',
+ BLOCK: 'block',
+};
+
+const DROP_POSITIONS = {
+ BEFORE: 'before',
+ AFTER: 'after',
+};
+
+function sectionsEqual(a, b) {
+ if (!a || !b || a.length !== b.length) return false;
+ return a.every((sec, i) => {
+ const other = b[i];
+ return sec.sectionIndex === other.sectionIndex
+ && sec.blocks.length === other.blocks.length
+ && sec.blocks.every((blk, j) => blk.name === other.blocks[j].name);
+ });
+}
+
+class EwPageOutline extends LitElement {
+ static properties = {
+ _sections: { state: true },
+ _selectedBlockIndex: { state: true },
+ };
+
+ _hashCtrl = new HashController(this);
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this._unsubscribeHtml = editorHtmlChange.subscribe((aemHtml) => {
+ if (aemHtml.trim()) {
+ const next = parseSections(aemHtml);
+ if (!sectionsEqual(next, this._sections)) this._sections = next;
+ } else {
+ this._sections = undefined;
+ this._selectedBlockIndex = undefined;
+ }
+ });
+ this._unsubscribeSelect = editorSelectChange
+ .subscribe(({ blockIndex, source }) => {
+ if (source === 'outline') return;
+ this._selectedBlockIndex = blockIndex;
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._unsubscribeHtml?.();
+ this._unsubscribeSelect?.();
+ }
+
+ get _selectedPath() {
+ const { org, site, path } = this._hashCtrl.value ?? {};
+ return org && site && path ? `${org}/${site}/${path}` : '';
+ }
+
+ willUpdate() {
+ const sp = this._selectedPath;
+ if (this._prevSelectedPath !== undefined && sp !== this._prevSelectedPath) {
+ this._sections = undefined;
+ this._selectedBlockIndex = undefined;
+ }
+ this._prevSelectedPath = sp;
+ }
+
+ _select(blockIndex) {
+ this._selectedBlockIndex = blockIndex;
+ editorSelectChange.emit({ blockIndex, source: 'outline' });
+ }
+
+ _clearDropIndicator() {
+ this.shadowRoot.querySelector('[data-drop-position]')?.removeAttribute('data-drop-position');
+ }
+
+ _setDropIndicator(el, data) {
+ this._clearDropIndicator();
+ el.dataset.dropPosition = data.dropPosition;
+ this._dropTarget = data;
+ }
+
+ _clearDragState() {
+ this._clearDropIndicator();
+ this._dragSourceEl?.classList.remove('dragging');
+ this._dragSourceEl = null;
+ this._dragging = null;
+ this._dropTarget = null;
+ }
+
+ _onDragStart(e, type, index) {
+ this._dragging = { type, index };
+ const el = type === OUTLINE_TYPES.SECTION ? e.currentTarget.parentElement : e.currentTarget;
+ el.classList.add('dragging');
+ this._dragSourceEl = el;
+ e.dataTransfer.effectAllowed = 'move';
+ }
+
+ _onSectionDragOver(e, sec) {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const dropPosition = e.clientY < rect.top + rect.height / 2
+ ? DROP_POSITIONS.BEFORE : DROP_POSITIONS.AFTER;
+
+ if (this._dragging?.type === OUTLINE_TYPES.SECTION) {
+ if (this._dragging.index === sec.sectionIndex) return;
+ e.preventDefault();
+
+ const el = dropPosition === DROP_POSITIONS.BEFORE
+ ? e.currentTarget.querySelector('[data-section-header]')
+ : e.currentTarget;
+
+ this._setDropIndicator(el, { sectionIndex: sec.sectionIndex, dropPosition });
+ } else {
+ if (!sec.blocks.length) return;
+ if (sec.blocks.some((b) => b.blockIndex === this._dragging?.index)) return;
+ const { blockIndex } = sec.blocks[sec.blocks.length - 1];
+ e.preventDefault();
+
+ const lastBlockEl = this.shadowRoot.querySelector(`[data-block-index="${blockIndex}"]`);
+ if (!lastBlockEl) return;
+ this._setDropIndicator(lastBlockEl, { blockIndex, dropPosition: DROP_POSITIONS.AFTER });
+ }
+ }
+
+ _onBlockDragOver(e, blockIndex) {
+ if (this._dragging?.type !== OUTLINE_TYPES.BLOCK || this._dragging.index === blockIndex) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ const dropPosition = e.clientY < rect.top + rect.height / 2
+ ? DROP_POSITIONS.BEFORE : DROP_POSITIONS.AFTER;
+ this._setDropIndicator(e.currentTarget, { blockIndex, dropPosition });
+ }
+
+ _onDrop = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const { _dragging, _dropTarget } = this;
+ this._clearDragState();
+ if (!_dropTarget || !_dragging) return;
+ const { view } = getExtensionsBridge();
+ if (_dropTarget.blockIndex != null) {
+ if (_dragging.type !== OUTLINE_TYPES.BLOCK) return;
+ moveBlock(view, _dragging.index, _dropTarget.blockIndex, _dropTarget.dropPosition);
+ } else if (_dropTarget.sectionIndex != null) {
+ if (_dragging.type !== OUTLINE_TYPES.SECTION) return;
+ moveSection(view, _dragging.index, _dropTarget.sectionIndex, _dropTarget.dropPosition);
+ }
+ };
+
+ _onDragEnd = () => {
+ this._clearDragState();
+ };
+
+ _onDragLeave = (e) => {
+ if (!e.currentTarget.contains(e.relatedTarget)) {
+ this._clearDropIndicator();
+ this._dropTarget = null;
+ }
+ };
+
+ _onTreeKeydown = (e) => treeKeydown(e, this.shadowRoot);
+
+ _renderSection(sec, isFirstSection) {
+ return html`
+
this._onSectionDragOver(e, sec)}
+ @dragleave=${this._onDragLeave}
+ @drop=${this._onDrop}>
+
+
+ ${sec.blocks.length === 0
+ ? html`-
+ No blocks
+
`
+ : sec.blocks.map(({ name, blockIndex }, blockIdx) => html`
+ - this._onDragStart(e, OUTLINE_TYPES.BLOCK, blockIndex)}
+ @dragover=${(e) => this._onBlockDragOver(e, blockIndex)}
+ @drop=${this._onDrop}
+ @dragend=${this._onDragEnd}
+ @click=${() => this._select(blockIndex)}>${name}
`)}
+
+ `;
+ }
+
+ render() {
+ if (!this._selectedPath) {
+ return html`
+
Select a page to see its outline.
+
`;
+ }
+
+ return html`
+
+
+ ${!this._sections
+ ? html`
No blocks found.
`
+ : html`
+ ${this._sections.map((sec, i) => this._renderSection(sec, i === 0))}
+
`}
+
+ `;
+ }
+}
+
+customElements.define('ew-page-outline', EwPageOutline);
diff --git a/blocks/canvas/ew-panel-extensions/aem-assets.js b/blocks/canvas/ew-panel-extensions/aem-assets.js
new file mode 100644
index 00000000..d221ded7
--- /dev/null
+++ b/blocks/canvas/ew-panel-extensions/aem-assets.js
@@ -0,0 +1,173 @@
+/* eslint-disable import/no-unresolved -- importmap */
+import { DOMParser as PMDOMParser } from 'da-y-wrapper';
+import { getNx } from '../../../scripts/utils.js';
+import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js';
+
+const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`);
+
+const ASSET_SELECTOR_URL = 'https://experience.adobe.com/solutions/CQ-assets-selectors/static-assets/resources/assets-selectors.js';
+const DEFAULT_BASE_PATH = '/adobe/assets';
+
+// ---------------------------------------------------------------------------
+// Config helpers
+// ---------------------------------------------------------------------------
+
+async function getRepositoryConfig(org, site) {
+ const configs = await Promise.all(fetchDaConfigs({ org, site }));
+ const entries = configs
+ .filter((c) => !c?.error)
+ .reverse()
+ .flatMap((c) => getFirstSheet(c) || []);
+ const getValue = (key) => entries.find((e) => e.key === key)?.value || null;
+
+ const repositoryId = getValue('aem.repositoryId');
+ if (!repositoryId) return null;
+
+ const tierType = repositoryId.startsWith('delivery') ? 'delivery' : 'author';
+ const customOrigin = getValue('aem.assets.prod.origin');
+ const isDmEnabled = getValue('aem.asset.dm.delivery') === 'on'
+ || getValue('aem.asset.smartcrop.select') === 'on'
+ || tierType === 'delivery';
+
+ let assetOrigin;
+ if (customOrigin) assetOrigin = customOrigin;
+ else if (tierType === 'delivery') assetOrigin = repositoryId;
+ else if (isDmEnabled) assetOrigin = repositoryId.replace('author', 'delivery');
+ else assetOrigin = repositoryId.replace('author', 'publish');
+
+ const assetBasePath = getValue('aem.assets.prod.basepath') || DEFAULT_BASE_PATH;
+
+ return { repositoryId, tierType, assetOrigin, assetBasePath, isDmEnabled };
+}
+
+// ---------------------------------------------------------------------------
+// URL builders
+// ---------------------------------------------------------------------------
+
+function buildDeliveryUrl(asset, host, basePath) {
+ const id = asset['repo:assetId'] || asset['repo:id'];
+ const name = asset['repo:name'] || asset.name || '';
+ const seoName = name.includes('.') ? name.split('.').slice(0, -1).join('.') : name;
+ return `https://${host}${basePath}/${id}/as/${seoName}.avif`;
+}
+
+function buildDmUrl(asset, host, basePath) {
+ const base = `https://${host}${basePath}/${asset['repo:id']}`;
+ const mimetype = (asset.mimetype || asset['dc:format'] || '').toLowerCase();
+ if (mimetype.startsWith('video/')) return `${base}/play`;
+ const seoName = asset.name?.includes('.')
+ ? asset.name.split('.').slice(0, -1).join('.')
+ : asset.name;
+ return `${base}/as/${seoName}.avif`;
+}
+
+function buildAuthorUrl(asset, publishOrigin) {
+ return `https://${publishOrigin}${asset.path}`;
+}
+
+function resolveAssetUrl(asset, config) {
+ const { tierType, assetOrigin, assetBasePath, isDmEnabled } = config;
+ if (tierType === 'delivery') return buildDeliveryUrl(asset, assetOrigin, assetBasePath);
+ if (isDmEnabled) return buildDmUrl(asset, assetOrigin, assetBasePath);
+ return buildAuthorUrl(asset, assetOrigin);
+}
+
+// ---------------------------------------------------------------------------
+// Insertion
+// ---------------------------------------------------------------------------
+
+function insertImage(view, src, alt) {
+ const attrs = { src, style: 'width: 180px' };
+ if (alt) attrs.alt = alt;
+ const node = view.state.schema.nodes.image.create(attrs);
+ view.dispatch(view.state.tr.replaceSelectionWith(node).scrollIntoView());
+}
+
+function insertLink(view, src) {
+ const para = document.createElement('p');
+ const link = document.createElement('a');
+ link.href = src;
+ link.innerText = src;
+ para.append(link);
+ const parsed = PMDOMParser.fromSchema(view.state.schema).parse(para);
+ view.dispatch(view.state.tr.replaceSelectionWith(parsed).scrollIntoView());
+}
+
+function getAssetAlt(asset) {
+ return asset['dc:title']?.['o:default']
+ || asset['dc:title']
+ || asset.name
+ || '';
+}
+
+// ---------------------------------------------------------------------------
+// Script loader
+// ---------------------------------------------------------------------------
+
+let selectorScriptLoaded;
+
+function loadSelectorScript() {
+ selectorScriptLoaded ??= new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = ASSET_SELECTOR_URL;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.append(script);
+ });
+ return selectorScriptLoaded;
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/** Renders the AEM asset selector into `container`; selections insert into the editor. */
+export async function renderAssets({ container, org, site, onClose }) {
+ const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`);
+ const ims = await loadIms();
+ if (ims?.anonymous) handleSignIn();
+ const token = ims?.accessToken?.token;
+ if (!token) return;
+
+ const repoConfig = await getRepositoryConfig(org, site);
+ if (!repoConfig) return;
+
+ await loadSelectorScript();
+
+ const selectorProps = {
+ imsToken: token,
+ repositoryId: repoConfig.repositoryId,
+ aemTierType: repoConfig.tierType,
+ featureSet: ['upload', 'collections', 'detail-panel', 'advisor'],
+ ...(onClose && { onClose }),
+ handleSelection: (assets) => {
+ const [asset] = assets;
+ if (!asset) return;
+ const { view } = getExtensionsBridge();
+ if (!view) return;
+ const src = resolveAssetUrl(asset, repoConfig);
+ const mimetype = (asset.mimetype || asset['dc:format'] || '').toLowerCase();
+ const alt = getAssetAlt(asset);
+ if (mimetype.startsWith('image/')) {
+ insertImage(view, src, alt);
+ } else {
+ insertLink(view, src);
+ }
+ },
+ };
+
+ window.PureJSSelectors.renderAssetSelector(container, selectorProps);
+}
+
+export function getAssetsPlugin({ org, site }) {
+ return {
+ name: 'aem-assets',
+ title: 'AEM Assets',
+ experience: 'fullsize-dialog',
+ ootb: false,
+ sources: [],
+ format: '',
+ org,
+ site,
+ };
+}
diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-extensions.css b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.css
new file mode 100644
index 00000000..25871011
--- /dev/null
+++ b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.css
@@ -0,0 +1,14 @@
+:host {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow-y: auto;
+}
+
+.ext-iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: none;
+}
diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js
new file mode 100644
index 00000000..c7b1de9a
--- /dev/null
+++ b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js
@@ -0,0 +1,57 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js';
+import './ew-panel-library.js';
+
+const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+class EwPanelExtension extends LitElement {
+ static properties = { extension: { attribute: false } };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this._hash = new HashController(this);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._destroyChannel?.();
+ }
+
+ async _handlePluginLoad({ target }) {
+ const hashState = this._hash.value || {};
+ const { setupIframeChannel } = await import('./iframe-protocol.js');
+ this._destroyChannel?.();
+ const { destroy } = await setupIframeChannel({
+ iframe: target,
+ hashState,
+ getView: () => getExtensionsBridge().view,
+ onClose: () => this.dispatchEvent(
+ new CustomEvent('nx-panel-close', { bubbles: true, composed: true }),
+ ),
+ });
+ this._destroyChannel = destroy;
+ }
+
+ render() {
+ const ext = this.extension;
+ if (!ext) return nothing;
+
+ if (ext.ootb) {
+ return html`
`;
+ }
+
+ return html`
`;
+ }
+}
+
+customElements.define('ew-panel-extension', EwPanelExtension);
diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-library.css b/blocks/canvas/ew-panel-extensions/ew-panel-library.css
new file mode 100644
index 00000000..dbbb1fdc
--- /dev/null
+++ b/blocks/canvas/ew-panel-extensions/ew-panel-library.css
@@ -0,0 +1,296 @@
+:host {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow-y: auto;
+}
+
+
+.ext-state {
+ padding: 16px;
+ color: light-dark(var(--s2-gray-700), var(--s2-gray-300));
+ font-size: 14px;
+}
+
+.ext-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.ext-group {
+ border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600));
+}
+
+.ext-group-header {
+ display: flex;
+ align-items: center;
+ width: 100%;
+}
+
+.ext-group-title {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+ padding: 10px 0 10px 16px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ font-size: 14px;
+ color: inherit;
+ gap: 8px;
+}
+
+.ext-group-title:hover {
+ background: light-dark(var(--s2-gray-100), var(--s2-gray-700));
+}
+
+.ext-expand-icon {
+ font-size: 12px;
+ color: light-dark(var(--s2-gray-600), var(--s2-gray-400));
+ flex-shrink: 0;
+}
+
+.ext-action-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ margin: 0;
+ border: none;
+ border-radius: 4px;
+ background: none;
+ cursor: pointer;
+ font-size: 14px;
+ color: light-dark(var(--s2-gray-600), var(--s2-gray-400));
+ flex-shrink: 0;
+}
+
+.ext-action-btn:hover {
+ background: light-dark(var(--s2-gray-200), var(--s2-gray-600));
+ color: light-dark(var(--s2-gray-900), var(--s2-gray-100));
+}
+
+.ext-preview-btn {
+ margin-right: 4px;
+}
+
+.ext-add-btn {
+ margin-right: 8px;
+}
+
+.ext-icon {
+ width: 20px;
+ height: 20px;
+ flex: 0 0 auto;
+ display: block;
+ color: inherit;
+}
+
+.ext-action-btn .ext-icon {
+ pointer-events: none;
+}
+
+.ext-variant-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600));
+}
+
+.ext-variants-loading {
+ padding: 8px 24px;
+ font-size: 13px;
+ color: light-dark(var(--s2-gray-600), var(--s2-gray-400));
+}
+
+.ext-variant-item {
+ padding: 8px 0 8px 24px;
+ border-bottom: 1px solid light-dark(var(--s2-gray-100), var(--s2-gray-700));
+}
+
+.ext-variant-item:hover {
+ background: light-dark(var(--s2-gray-100), var(--s2-gray-700));
+}
+
+.ext-variant-header {
+ display: flex;
+ align-items: center;
+}
+
+.ext-variant-title {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ background: none;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ padding: 0;
+ color: inherit;
+}
+
+.ext-variant-name {
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.ext-variant-subtitle {
+ font-size: 11px;
+ color: light-dark(var(--s2-gray-500), var(--s2-gray-400));
+ margin-top: 2px;
+}
+
+.ext-variant-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.ext-description {
+ padding: 6px 16px 6px 0;
+ font-size: 12px;
+ line-height: 1.4;
+ color: light-dark(var(--s2-gray-600), var(--s2-gray-400));
+}
+
+.ext-item {
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600));
+}
+
+.ext-item:hover {
+ background: light-dark(var(--s2-gray-100), var(--s2-gray-700));
+}
+
+.ext-item-title {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+ padding: 8px 0 8px 16px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ font-size: 14px;
+ color: inherit;
+ gap: 8px;
+}
+
+.ext-item-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.ext-item-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ext-item-value {
+ flex-shrink: 0;
+ margin-left: 8px;
+ font-size: 12px;
+ color: light-dark(var(--s2-gray-600), var(--s2-gray-400));
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 120px;
+}
+
+.ext-preview-dialog {
+ position: fixed;
+ inset: 0;
+ width: 90vw;
+ max-width: 1200px;
+ height: 80vh;
+ margin: auto;
+ padding: 0;
+ border: 1px solid light-dark(var(--s2-gray-300), var(--s2-gray-600));
+ border-radius: 8px;
+ background: light-dark(var(--s2-gray-50), var(--s2-gray-800));
+ color: inherit;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.ext-preview-dialog::backdrop {
+ background: rgb(0 0 0 / 40%);
+}
+
+.ext-preview-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600));
+}
+
+.ext-preview-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.ext-preview-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ background: none;
+ cursor: pointer;
+ font-size: 16px;
+ color: inherit;
+}
+
+.ext-preview-close:hover {
+ background: light-dark(var(--s2-gray-200), var(--s2-gray-600));
+}
+
+.ext-preview-body {
+ flex: 1;
+ position: relative;
+ overflow: hidden;
+}
+
+.ext-preview-frame {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: none;
+}
+
+.ext-preview-frame.hide-iframe {
+ visibility: hidden;
+}
+
+.ext-preview-error {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: light-dark(var(--s2-gray-50), var(--s2-gray-800));
+ font-size: 14px;
+ color: light-dark(var(--s2-gray-700), var(--s2-gray-300));
+}
diff --git a/blocks/canvas/ew-panel-extensions/ew-panel-library.js b/blocks/canvas/ew-panel-extensions/ew-panel-library.js
new file mode 100644
index 00000000..a69bc93d
--- /dev/null
+++ b/blocks/canvas/ew-panel-extensions/ew-panel-library.js
@@ -0,0 +1,269 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import {
+ fetchBlocks,
+ fetchItems,
+ insertBlock,
+ insertText,
+ insertTemplate,
+ getPreviewStatus,
+ getItemPreviewUrl,
+} from './helpers.js';
+import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js';
+
+const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`);
+const style = await loadStyle(import.meta.url);
+
+const iconAdd = () => html`

`;
+const iconPreview = () => html`

`;
+
+/**
+ * First-party library panel: blocks, templates, icons, placeholders (OOTB sheet-driven tools).
+ */
+class EwPanelLibrary extends LitElement {
+ static properties = {
+ extension: { attribute: false },
+ _items: { state: true },
+ _blockVariants: { state: true },
+ _expandedBlock: { state: true },
+ _preview: { state: true },
+ _tooltipOpen: { state: true },
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this._hash = new HashController(this);
+ }
+
+ willUpdate(changed) {
+ if (changed.has('extension') && this.extension) {
+ this._items = undefined;
+ this._blockVariants = new Map();
+ this._expandedBlock = null;
+ this._preview = undefined;
+ this._tooltipOpen = null;
+ this._loadItems();
+ }
+ }
+
+ updated() {
+ const dialog = this.shadowRoot.querySelector('dialog');
+ if (dialog && !dialog.open) dialog.showModal();
+ }
+
+ async _loadItems() {
+ const ext = this.extension;
+ if (!ext) return;
+
+ if (!ext.ootb) return;
+
+ if (ext.name === 'blocks') {
+ this._items = await fetchBlocks(ext.sources);
+ return;
+ }
+
+ let defaultFormat = '';
+ if (ext.name === 'icons') defaultFormat = ':
:';
+ else if (ext.name === 'placeholders') defaultFormat = '{{}}';
+ this._items = await fetchItems(ext.sources, ext.format || defaultFormat);
+ }
+
+ async _toggleBlock(block) {
+ if (this._expandedBlock === block.path) {
+ this._expandedBlock = null;
+ return;
+ }
+ this._expandedBlock = block.path;
+ if (!this._blockVariants.has(block.path)) {
+ const variants = await block.loadVariants;
+ const next = new Map(this._blockVariants);
+ next.set(block.path, variants ?? []);
+ this._blockVariants = next;
+ }
+ }
+
+ _insertBlock(variant) {
+ const { view } = getExtensionsBridge();
+ if (!view) return;
+ insertBlock(view, variant.dom);
+ }
+
+ _insertText(item) {
+ const { view } = getExtensionsBridge();
+ if (!view) return;
+ insertText(view, item.text);
+ }
+
+ async _insertTemplate(item) {
+ const { view } = getExtensionsBridge();
+ if (!view) return;
+ await insertTemplate(view, item.path);
+ }
+
+ async _openPreview(item) {
+ const { org, site } = this._hash.value || {};
+ if (!org || !site) return;
+ const details = getItemPreviewUrl(item, { org, site });
+ this._preview = {
+ name: item.name || item.key || item.title,
+ url: details.previewUrl,
+ };
+ this._preview.ok = await getPreviewStatus({
+ org: details.org,
+ site: details.site,
+ pathname: details.pathname,
+ });
+ this.requestUpdate();
+ }
+
+ async _closePreview() {
+ this._preview = undefined;
+ await this.updateComplete;
+ this.shadowRoot.querySelector('button')?.focus();
+ }
+
+ _toggleTooltip(key) {
+ this._tooltipOpen = this._tooltipOpen === key ? null : key;
+ }
+
+ _renderVariants(block) {
+ if (this._expandedBlock !== block.path) return nothing;
+ const variants = this._blockVariants.get(block.path);
+ if (variants === undefined) {
+ return html`Loading variants…
`;
+ }
+ if (!variants.length) {
+ return html`No variants found.
`;
+ }
+ return html`
+
+ ${variants.map((v) => html`
+ -
+
+ ${v.description && this._tooltipOpen === v.name
+ ? html`
${v.description}
` : nothing}
+
+ `)}
+
+ `;
+ }
+
+ _renderBlocks() {
+ if (this._items === undefined) return html`Loading…
`;
+ if (!this._items.length) return html`No blocks found.
`;
+ return html`
+
+ ${this._items.map((block) => html`
+ -
+
+ ${this._renderVariants(block)}
+
+ `)}
+
+ `;
+ }
+
+ _renderTemplates() {
+ if (this._items === undefined) return html`Loading…
`;
+ if (!this._items.length) return html`No templates found.
`;
+ return html`
+
+ `;
+ }
+
+ _renderKeyValueItems(label) {
+ if (this._items === undefined) return html`Loading…
`;
+ if (!this._items.length) return html`No ${label} found.
`;
+ return html`
+
+ `;
+ }
+
+ _renderPreviewDialog() {
+ if (!this._preview) return nothing;
+ const { ok, name, url } = this._preview;
+ const hideIframe = ok === undefined || ok === false ? 'hide-iframe' : '';
+ const error = ok === false
+ ? `It appears ${name} has not been previewed.`
+ : undefined;
+
+ return html`
+
+ `;
+ }
+
+ render() {
+ const ext = this.extension;
+ if (!ext) {
+ return html`No extension.
`;
+ }
+
+ const body = (() => {
+ if (ext.name === 'blocks') return this._renderBlocks();
+ if (ext.name === 'templates') return this._renderTemplates();
+ return this._renderKeyValueItems(ext.name);
+ })();
+
+ return html`${body}${this._renderPreviewDialog()}`;
+ }
+}
+
+customElements.define('ew-panel-library', EwPanelLibrary);
diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js
new file mode 100644
index 00000000..c2307da3
--- /dev/null
+++ b/blocks/canvas/ew-panel-extensions/helpers.js
@@ -0,0 +1,479 @@
+/* eslint-disable import/no-unresolved -- importmap */
+import { DOMParser as PMDOMParser, DOMSerializer, Slice, TextSelection } from 'da-y-wrapper';
+import { getNx } from '../../../scripts/utils.js';
+import { daFetch } from '../../shared/utils.js';
+import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js';
+
+const { HLX_ADMIN, hashChange } = await import(`${getNx()}/utils/utils.js`);
+const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`);
+
+const ref = new URLSearchParams(window.location.search).get('ref') || 'main';
+
+const AEM_ORIGINS = ['hlx.page', 'hlx.live', 'aem.page', 'aem.live'];
+const REPLACE_CONTENT = '';
+
+// ---------------------------------------------------------------------------
+// Block HTML parsing — ported from da-live helpers/index.js
+// ---------------------------------------------------------------------------
+
+function isHeading(el) {
+ return ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(el?.nodeName);
+}
+
+function getBlockName(className) {
+ const [name, ...rest] = (className || '').split(' ');
+ return { name, variants: rest.length ? rest.join(', ') : undefined };
+}
+
+function getBlockTableHtml(block) {
+ const { name, variants } = getBlockName(block.className);
+ const rows = [...block.children];
+ const maxCols = rows.reduce((n, row) => Math.max(n, row.children.length), 0) || 1;
+
+ const table = document.createElement('table');
+ table.setAttribute('border', '1');
+
+ const headerRow = document.createElement('tr');
+ const th = document.createElement('td');
+ th.setAttribute('colspan', String(maxCols));
+ th.textContent = variants ? `${name} (${variants})` : name;
+ headerRow.append(th);
+ table.append(headerRow);
+
+ rows.forEach((row) => {
+ const tr = document.createElement('tr');
+ [...row.children].forEach((col) => {
+ const td = document.createElement('td');
+ if (row.children.length < maxCols) td.setAttribute('colspan', String(maxCols));
+ td.innerHTML = col.innerHTML;
+ tr.append(td);
+ });
+ table.append(tr);
+ });
+
+ return table;
+}
+
+function decorateImages(element, path) {
+ try {
+ const { origin } = new URL(path);
+ element.querySelectorAll('img').forEach((img) => {
+ if (img.getAttribute('src')?.startsWith('./')) {
+ img.src = `${origin}/${img.src.split('/').pop()}`;
+ }
+ const ratio = img.width > 200 ? 200 / img.width : 1;
+ img.width = Math.round(img.width * ratio);
+ img.height = Math.round(img.height * ratio);
+ });
+ } catch { /* leave images as-is */ }
+}
+
+async function fetchAndParseHtml(path, isAemHosted) {
+ try {
+ const resp = await daFetch(`${path}${isAemHosted ? '.plain.html' : ''}`, { noRedirect: true });
+ if (!resp.ok) return null;
+ return new window.DOMParser().parseFromString(await resp.text(), 'text/html');
+ } catch { return null; }
+}
+
+function getSectionsAndBlocks(doc) {
+ return [...doc.querySelectorAll('body > div, main > div')].reduce((acc, section) => {
+ const hr = document.createElement('hr');
+ hr.dataset.issection = 'true';
+ acc.push(hr, ...section.querySelectorAll(':scope > *'));
+ return acc;
+ }, []);
+}
+
+function processGroupBlock(block) {
+ const container = document.createElement('div');
+ [...block.children].forEach((child) => {
+ container.append(child.tagName === 'DIV' ? getBlockTableHtml(child) : child.cloneNode(true));
+ });
+ return container;
+}
+
+function groupBlocks(elements) {
+ return elements.reduce((state, el) => {
+ if (el.classList?.contains('library-container-start')) {
+ const blockGroup = document.createElement('div');
+ blockGroup.dataset.isgroup = 'true';
+ if (isHeading(el.previousElementSibling)) {
+ blockGroup.dataset.groupheading = el.previousElementSibling.textContent;
+ }
+ state.currentGroup = { blockGroup };
+ } else if (el.classList?.contains('library-container-end') && state.currentGroup) {
+ const { blockGroup } = state.currentGroup;
+ if (el.nextElementSibling?.classList.contains('library-metadata')) {
+ blockGroup.append(el.nextElementSibling.cloneNode(true));
+ }
+ state.blocks.push(blockGroup);
+ state.currentGroup = null;
+ } else if (state.currentGroup) {
+ state.currentGroup.blockGroup.append(el.cloneNode(true));
+ } else if (
+ el.nodeName === 'DIV'
+ && !el.dataset?.issection
+ && !el.classList?.contains('library-metadata')
+ ) {
+ state.blocks.push(el);
+ }
+ return state;
+ }, { blocks: [], currentGroup: null }).blocks;
+}
+
+function getLibraryMetadata(el) {
+ return [...el.childNodes].reduce((acc, row) => {
+ if (row.children) {
+ const key = row.children[0]?.textContent.trim().toLowerCase();
+ const val = row.children[1]?.textContent.trim();
+ if (key && val) acc[key] = val;
+ }
+ return acc;
+ }, {});
+}
+
+function transformBlock(block) {
+ const prevSib = block.previousElementSibling;
+ const item = isHeading(prevSib) && prevSib.textContent
+ ? { name: prevSib.textContent }
+ : getBlockName(block.className || '');
+ item.dom = block.dataset?.isgroup ? processGroupBlock(block) : getBlockTableHtml(block);
+
+ const metaEl = block.nextElementSibling?.classList.contains('library-metadata')
+ ? block.nextElementSibling
+ : block.querySelector('.library-metadata');
+ if (metaEl) {
+ const md = getLibraryMetadata(metaEl);
+ if (md.searchtags) item.tags = md.searchtags;
+ if (md.description) item.description = md.description;
+ }
+ return item;
+}
+
+export async function getBlockVariants(path) {
+ let isAemHosted = false;
+ try {
+ isAemHosted = AEM_ORIGINS.some((o) => new URL(path).origin.endsWith(o));
+ } catch { /* relative path */ }
+
+ const doc = await fetchAndParseHtml(path, isAemHosted);
+ if (!doc) return [];
+
+ decorateImages(doc.body, path);
+ return groupBlocks(getSectionsAndBlocks(doc)).map(transformBlock);
+}
+
+// ---------------------------------------------------------------------------
+// Extension config
+// ---------------------------------------------------------------------------
+
+const OOTB_PLUGINS = new Set(['blocks', 'templates', 'icons', 'placeholders']);
+
+/** First-party library tools + AEM Assets (not flagged `ootb` in plugin metadata). */
+const LIBRARY_PLUGIN_NAMES = new Set([...OOTB_PLUGINS, 'aem-assets']);
+
+const LIBRARY_PANEL_ORDER = ['blocks', 'icons', 'templates', 'placeholders', 'aem-assets'];
+
+function isLibraryExtension(ext) {
+ return LIBRARY_PLUGIN_NAMES.has(ext.name);
+}
+
+function sortLibraryExtensions(list) {
+ const orderOf = (name) => {
+ const i = LIBRARY_PANEL_ORDER.indexOf(name);
+ return i === -1 ? LIBRARY_PANEL_ORDER.length + 1 : i;
+ };
+ return [...list].sort((a, b) => orderOf(a.name) - orderOf(b.name));
+}
+
+function getIsPluginAllowed(plugRef) {
+ const pluginRef = plugRef || 'main';
+ if (pluginRef === 'main') return true;
+ if (ref === 'local') return true;
+ return pluginRef === ref;
+}
+
+function calculateSources(org, site, sheetPath) {
+ return sheetPath.split(',').map((p) => {
+ const trimmed = p.trim();
+ if (!trimmed.startsWith('/')) return trimmed;
+ if (ref === 'local') return `http://localhost:3000${trimmed}`;
+ return `https://${ref}--${site}--${org}.aem.live${trimmed}`;
+ });
+}
+
+function mergePlugin(list, plugin) {
+ let idx = list.findIndex((p) => p.name === 'templates');
+ if (idx === -1) idx = list.findIndex((p) => p.name === 'blocks');
+ if (idx !== -1) {
+ list.splice(idx + 1, 0, plugin);
+ } else {
+ list.push(plugin);
+ }
+}
+
+export async function fetchExtensions(org, site) {
+ const configs = fetchDaConfigs({ org, site });
+ const siteConfig = await configs[configs.length - 1];
+ if (siteConfig?.error) return [];
+
+ const rows = siteConfig?.library?.data;
+ if (!Array.isArray(rows)) return [];
+
+ const extensions = rows.reduce((acc, row) => {
+ if (!row.title || !getIsPluginAllowed(row.ref)) return acc;
+ const name = row.title.trim().toLowerCase().replaceAll(' ', '-');
+ acc.push({
+ name,
+ title: row.title.trim(),
+ sources: calculateSources(org, site, row.path),
+ experience: row.experience || 'inline',
+ format: row.format || '',
+ ootb: OOTB_PLUGINS.has(name),
+ });
+ return acc;
+ }, []);
+
+ try {
+ const siteEntries = getFirstSheet(siteConfig) || [];
+ const hasRepo = siteEntries.find((e) => e.key === 'aem.repositoryId')?.value;
+ if (hasRepo) {
+ const { getAssetsPlugin } = await import('./aem-assets.js');
+ const plugin = getAssetsPlugin({ org, site });
+ if (plugin) mergePlugin(extensions, plugin);
+ }
+ } catch { /* proceed without assets */ }
+
+ return extensions;
+}
+
+// ---------------------------------------------------------------------------
+// Data fetching
+// ---------------------------------------------------------------------------
+
+export async function fetchBlocks(sources) {
+ const blocks = [];
+ for (const url of sources) {
+ try {
+ const resp = await daFetch(url, { noRedirect: true });
+ if (resp.ok) {
+ const json = await resp.json();
+ const data = getFirstSheet(json) ?? (Array.isArray(json) ? json : []);
+ data.forEach((row) => {
+ if (row.name && row.path) {
+ blocks.push({ ...row, loadVariants: getBlockVariants(row.path) });
+ }
+ });
+ }
+ } catch { /* skip failed source */ }
+ }
+ return blocks;
+}
+
+export async function fetchItems(sources, format) {
+ const items = [];
+ for (const source of sources) {
+ try {
+ const resp = await daFetch(source, { noRedirect: true });
+ if (resp.ok) {
+ const json = await resp.json();
+ const data = getFirstSheet(json) ?? (Array.isArray(json) ? json : []);
+ data.forEach((row) => {
+ const key = row.key ?? row.name;
+ if (!key && !row.value) return;
+ const text = format ? format.replace(REPLACE_CONTENT, key ?? '') : (key ?? '');
+ items.push({ ...row, key: key ?? '', text });
+ });
+ }
+ } catch { /* skip failed source */ }
+ }
+ return items;
+}
+
+// ---------------------------------------------------------------------------
+// Content insertion
+// ---------------------------------------------------------------------------
+
+export function insertBlock(view, dom) {
+ const parsed = PMDOMParser.fromSchema(view.state.schema).parse(dom);
+ const { tr, schema } = view.state;
+ const insertPos = tr.selection.from;
+ let newTr = tr.insert(insertPos, schema.nodes.paragraph.create());
+ newTr = newTr.replaceSelectionWith(parsed);
+ const finalPos = Math.min(insertPos + parsed.nodeSize, newTr.doc.content.size);
+ view.dispatch(newTr.setSelection(TextSelection.create(newTr.doc, finalPos)).scrollIntoView());
+}
+
+export function insertText(view, text) {
+ const node = view.state.schema.text(text);
+ view.dispatch(view.state.tr.replaceSelectionWith(node).scrollIntoView());
+}
+
+export function insertHTML(view, htmlStr) {
+ const doc = new window.DOMParser().parseFromString(htmlStr, 'text/html');
+ const parsed = PMDOMParser.fromSchema(view.state.schema).parse(doc.body);
+ const slice = new Slice(parsed.content, 0, 0);
+ const { from, to } = view.state.selection;
+ view.dispatch(view.state.tr.replaceRange(from, to, slice).scrollIntoView());
+}
+
+export function getEditorSelection(view) {
+ const { selection } = view.state;
+ if (selection.empty) return null;
+ const slice = selection.content();
+ const serializer = DOMSerializer.fromSchema(view.state.schema);
+ const fragment = serializer.serializeFragment(slice.content);
+ const div = document.createElement('div');
+ div.appendChild(fragment);
+ return div.innerHTML;
+}
+
+export async function insertTemplate(view, url) {
+ const resp = await daFetch(url);
+ if (!resp.ok) return;
+ const html = (await resp.text()).replace('class="template-metadata"', 'class="metadata"');
+ const doc = new window.DOMParser().parseFromString(html, 'text/html');
+ const parsed = PMDOMParser.fromSchema(view.state.schema).parse(doc.body);
+ view.dispatch(view.state.tr.replaceSelectionWith(parsed).scrollIntoView());
+}
+
+// ---------------------------------------------------------------------------
+// Preview status
+// ---------------------------------------------------------------------------
+
+export async function getPreviewStatus({ org, site, pathname }) {
+ try {
+ const resp = await daFetch(`${HLX_ADMIN}/status/${org}/${site}${pathname}`);
+ if (!resp.ok) return null;
+ const json = await resp.json();
+ return json.preview?.status === 200;
+ } catch {
+ return null;
+ }
+}
+
+export function getItemPreviewUrl(item, { org, site }) {
+ const url = new URL(item.path || item.value);
+ const { hostname, pathname } = url;
+
+ let itemOrg = org;
+ let itemSite = site;
+ let itemPath = pathname;
+
+ if (hostname.includes('.aem.')) {
+ const parts = hostname.split('.')[0].split('--').reverse();
+ [itemOrg, itemSite] = parts;
+ } else if (hostname.includes('content.da.live')) {
+ const segments = pathname.slice(1).split('/');
+ [itemOrg, itemSite] = segments;
+ itemPath = `/${segments.slice(2).join('/')}`;
+ }
+
+ return {
+ previewUrl: `https://${ref}--${itemSite}--${itemOrg}.aem.page${itemPath}`,
+ org: itemOrg,
+ site: itemSite,
+ pathname: itemPath,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// View facade — canvas.js calls this, nothing else
+// ---------------------------------------------------------------------------
+
+function createOutlineView() {
+ return {
+ id: 'outline',
+ label: 'Outline',
+ section: 'Editor',
+ firstParty: true,
+ load: async () => {
+ await import('../ew-page-outline/ew-page-outline.js');
+ return document.createElement('ew-page-outline');
+ },
+ };
+}
+
+function createFileExplorerView() {
+ return {
+ id: 'files',
+ label: 'Files',
+ section: 'Editor',
+ firstParty: true,
+ load: async () => {
+ await import('../ew-file-explorer/ew-file-explorer.js');
+ return document.createElement('ew-file-explorer');
+ },
+ };
+}
+
+function extensionToPanelView(ext, section) {
+ const view = {
+ id: ext.name,
+ label: ext.title,
+ section,
+ firstParty: ext.ootb,
+ experience: ext.experience,
+ sources: ext.sources,
+ load: async () => {
+ await import('./ew-panel-extensions.js');
+ const el = document.createElement('ew-panel-extension');
+ el.extension = ext;
+ return el;
+ },
+ };
+
+ if (ext.experience === 'fullsize-dialog') {
+ view.loadModal = async (container, onClose) => {
+ if (ext.name === 'aem-assets') {
+ const { renderAssets } = await import('./aem-assets.js');
+ await renderAssets({ container, org: ext.org, site: ext.site, onClose });
+ return () => {};
+ }
+
+ const iframe = document.createElement('iframe');
+ iframe.className = 'ext-iframe';
+ iframe.src = ext.sources?.[0] ?? '';
+ iframe.title = ext.title;
+ iframe.allow = 'clipboard-write *';
+ container.append(iframe);
+
+ let destroyChannel = () => {};
+ iframe.addEventListener('load', async () => {
+ let hashState;
+ const unsub = hashChange.subscribe((s) => { hashState = s; });
+ unsub();
+ const { setupIframeChannel } = await import('./iframe-protocol.js');
+ const { destroy } = await setupIframeChannel({
+ iframe,
+ hashState: hashState ?? {},
+ getView: () => getExtensionsBridge().view,
+ onClose,
+ });
+ destroyChannel = destroy;
+ }, { once: true });
+
+ return () => destroyChannel();
+ };
+ }
+
+ return view;
+}
+
+/**
+ * Tool panel: Editor placeholder, Library (blocks / AEM Assets / icons / templates / placeholders),
+ * Extensions (other plugins).
+ */
+export async function getCanvasToolPanelViews({ org, site }) {
+ const extensions = await fetchExtensions(org, site);
+ const library = sortLibraryExtensions(extensions.filter(isLibraryExtension));
+ const thirdParty = extensions.filter((ext) => !isLibraryExtension(ext));
+
+ return [
+ createOutlineView(),
+ createFileExplorerView(),
+ ...library.map((ext) => extensionToPanelView(ext, 'Library')),
+ ...thirdParty.map((ext) => extensionToPanelView(ext, 'Extensions')),
+ ];
+}
diff --git a/blocks/canvas/ew-panel-extensions/iframe-protocol.js b/blocks/canvas/ew-panel-extensions/iframe-protocol.js
new file mode 100644
index 00000000..c2a35ce7
--- /dev/null
+++ b/blocks/canvas/ew-panel-extensions/iframe-protocol.js
@@ -0,0 +1,90 @@
+import { insertText, insertHTML, getEditorSelection } from './helpers.js';
+import { getNx } from '../../../scripts/utils.js';
+
+/**
+ * Wire a two-way MessageChannel between the host and a BYO plugin iframe.
+ *
+ * @param {object} opts
+ * @param {HTMLIFrameElement} opts.iframe
+ * @param {object} opts.hashState
+ * @param {Function} opts.getView
+ * @param {Function} opts.onClose
+ * @returns {{ channel: MessageChannel, destroy: () => void }}
+ */
+export async function setupIframeChannel({ iframe, hashState, getView, onClose }) {
+ const { org, site, path, view } = hashState;
+ if (!org || !site || !iframe.contentWindow) return { channel: null, destroy() {} };
+
+ const channel = new MessageChannel();
+
+ channel.port1.onmessage = (e) => {
+ const { action, details } = e.data || {};
+ const editorView = getView();
+
+ if (action === 'sendText' && editorView) {
+ insertText(editorView, details);
+ }
+
+ if (action === 'sendHTML' && editorView) {
+ insertHTML(editorView, details);
+ }
+
+ if (action === 'setHash') {
+ window.location.hash = details;
+ }
+
+ if (action === 'setHref') {
+ window.location.href = details;
+ }
+
+ if (action === 'closeLibrary') {
+ onClose();
+ }
+
+ if (action === 'getSelection') {
+ if (!editorView) {
+ channel.port1.postMessage({ action: 'error', details: 'No editor view' });
+ return;
+ }
+ const html = getEditorSelection(editorView);
+ if (!html) {
+ channel.port1.postMessage({ action: 'error', details: 'No selection found' });
+ return;
+ }
+ iframe.contentWindow.postMessage(
+ { action: 'sendSelection', details: html },
+ '*',
+ );
+ }
+ };
+
+ const project = {
+ org,
+ repo: site,
+ ref: 'main',
+ path: path ? `/${path}` : '/',
+ view: view || 'edit',
+ };
+
+ let token;
+ try {
+ const { loadIms } = await import(`${getNx()}/utils/ims.js`);
+ const ims = await loadIms();
+ token = ims?.accessToken?.token;
+ } catch { /* proceed without token */ }
+
+ setTimeout(() => {
+ if (!iframe.contentWindow) return;
+ iframe.contentWindow.postMessage(
+ { ready: true, project, context: project, token },
+ '*',
+ [channel.port2],
+ );
+ }, 750);
+
+ const destroy = () => {
+ channel.port1.close();
+ };
+
+ return { channel, destroy };
+}
diff --git a/blocks/canvas/ew-panel-header/ew-panel-header.css b/blocks/canvas/ew-panel-header/ew-panel-header.css
new file mode 100644
index 00000000..fd1640ce
--- /dev/null
+++ b/blocks/canvas/ew-panel-header/ew-panel-header.css
@@ -0,0 +1,53 @@
+.panel-header {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ min-height: 48px;
+ padding: 0 8px;
+}
+
+.panel-header-custom {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ justify-content: flex-end;
+ padding-right: var(--s2-spacing-75);
+}
+
+.panel-header-action[hidden] {
+ display: none;
+}
+
+.panel-header-action,
+.panel-header-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 24px;
+ min-height: 24px;
+ padding: 0 4px;
+ margin: 0;
+ border: none;
+ border-radius: var(--s2-corner-radius-400);
+ color: var(--s2-gray-800);
+ background: transparent;
+ cursor: pointer;
+ gap: var(--s2-spacing-75);
+ font-size: var(--s2-body-size-s);
+
+ img {
+ display: block;
+ width: 16px;
+ height: 16px;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--s2-blue-800);
+ outline-offset: 2px;
+ }
+
+ &:hover {
+ background-color: var(--s2-gray-75);
+ }
+}
diff --git a/blocks/canvas/ew-panel-header/ew-panel-header.js b/blocks/canvas/ew-panel-header/ew-panel-header.js
new file mode 100644
index 00000000..cd994aa0
--- /dev/null
+++ b/blocks/canvas/ew-panel-header/ew-panel-header.js
@@ -0,0 +1,33 @@
+import { getNx } from '../../../scripts/utils.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+export default function createPanelHeader({ position, onClose }) {
+ if (!document.adoptedStyleSheets.includes(style)) {
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, style];
+ }
+ const side = position === 'before' ? 'left' : 'right';
+
+ const bar = document.createElement('div');
+ bar.className = 'panel-header';
+
+ const start = document.createElement('div');
+ start.className = 'panel-header-custom';
+
+ const toggleBtn = document.createElement('button');
+ toggleBtn.type = 'button';
+ toggleBtn.className = 'panel-header-toggle';
+ toggleBtn.setAttribute('aria-label', `Toggle ${position} panel`);
+
+ const img = document.createElement('img');
+ img.src = `/blocks/canvas/img/s2-icon-split${side}-20-n.svg`;
+ img.setAttribute('aria-hidden', 'true');
+ toggleBtn.append(img);
+
+ toggleBtn.addEventListener('click', onClose);
+
+ bar.append(start, toggleBtn);
+ return bar;
+}
diff --git a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.css b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.css
new file mode 100644
index 00000000..91efcaab
--- /dev/null
+++ b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.css
@@ -0,0 +1,169 @@
+:host {
+ display: contents;
+}
+
+.toolbar-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+ max-width: min(92vw, 500px);
+}
+
+.toolbar-actions[data-disabled] {
+ pointer-events: none;
+ opacity: 0.45;
+}
+
+.toolbar-btn {
+ box-sizing: border-box;
+ min-width: 24px;
+ height: 24px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: var(--s2-corner-radius-100);
+ background: none;
+ color: var(--s2-gray-800);
+ font: inherit;
+ cursor: pointer;
+}
+
+.toolbar-btn:hover {
+ background: var(--s2-gray-200);
+}
+
+.toolbar-btn[aria-pressed="true"] {
+ background: var(--s2-blue-200);
+ color: var(--s2-blue-900);
+}
+
+.toolbar-btn[aria-pressed="true"]:hover {
+ background: var(--s2-blue-300);
+}
+
+.toolbar-btn[data-mark="strong"] {
+ font-weight: 700;
+}
+
+.toolbar-btn[data-mark="em"] {
+ font-style: italic;
+}
+
+.toolbar-btn[data-mark="code"] {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.7rem;
+ letter-spacing: -0.02em;
+ padding: 0;
+}
+
+.toolbar-btn[hidden] {
+ display: none;
+}
+
+.toolbar-btn img {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.toolbar-sep {
+ width: 1px;
+ height: 16px;
+ background: var(--s2-gray-300);
+ margin: 0 2px;
+}
+
+.toolbar-block-type-wrap {
+ display: inline-flex;
+ align-items: center;
+}
+
+/* Link dialog */
+
+.link-dialog {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgb(0 0 0 / 40%);
+}
+
+.link-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2-spacing-100);
+ padding: var(--s2-spacing-200);
+ min-width: 340px;
+ max-width: min(90vw, 420px);
+ background: var(--s2-gray-25);
+ border: 1px solid var(--s2-gray-200);
+ border-radius: var(--s2-corner-radius-500);
+ box-shadow: 0 8px 32px rgb(0 0 0 / 18%);
+}
+
+.link-form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 0.75rem;
+ color: var(--s2-gray-700);
+}
+
+.link-form-field input {
+ box-sizing: border-box;
+ height: 32px;
+ padding: 0 var(--s2-spacing-100);
+ border: 1px solid var(--s2-gray-300);
+ border-radius: var(--s2-corner-radius-100);
+ background: var(--s2-gray-50);
+ color: var(--s2-gray-900);
+ font: inherit;
+ font-size: 0.875rem;
+}
+
+.link-form-field input:focus {
+ outline: 2px solid var(--s2-blue-600);
+ outline-offset: -1px;
+ border-color: transparent;
+}
+
+.link-form-actions {
+ display: flex;
+ gap: var(--s2-spacing-75);
+ justify-content: flex-end;
+ margin-top: var(--s2-spacing-50);
+}
+
+.link-form-actions button {
+ height: 32px;
+ padding: 0 16px;
+ border: none;
+ border-radius: var(--s2-corner-radius-800);
+ font: inherit;
+ font-size: var(--s2-body-size-s, 0.875rem);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.link-form-cancel {
+ background: var(--s2-gray-200);
+ color: var(--s2-gray-800);
+}
+
+.link-form-cancel:hover {
+ background: var(--s2-gray-300);
+}
+
+.link-form-save {
+ background: var(--s2-blue-900);
+ color: light-dark(var(--s2-gray-25), #fff);
+}
+
+.link-form-save:hover {
+ background: var(--s2-blue-1000);
+}
diff --git a/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js
new file mode 100644
index 00000000..9247cf1a
--- /dev/null
+++ b/blocks/canvas/ew-selection-toolbar/ew-selection-toolbar.js
@@ -0,0 +1,301 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { commandsFor, COMMAND_BY_ID } from '../editor-utils/command-defs.js';
+import {
+ getBlockTypePickerValue,
+ selectionHasLink,
+ getLinkInfoInSelection,
+ applyLink,
+ removeLink,
+} from '../editor-utils/command-helpers.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+await import(`${getNx()}/blocks/shared/popover/popover.js`);
+await import(`${getNx()}/blocks/shared/picker/picker.js`);
+
+const styles = await loadStyle(import.meta.url);
+
+const MARK_ITEMS = commandsFor('toolbar-marks');
+const STRUCTURE_ITEMS = commandsFor('toolbar-structure');
+const PICKER_DEFS = commandsFor('toolbar-picker');
+
+const BLOCK_TYPE_LABELS = new Map(PICKER_DEFS.map(({ id, label }) => [id, label]));
+
+const BLOCK_TYPE_PICKER_ITEMS = [
+ { section: 'Change into' },
+ ...PICKER_DEFS.map(({ id, label }) => ({ value: id, label })),
+];
+
+const LINK_ICON = new URL('../img/s2-icon-link-20-n.svg', import.meta.url).href;
+const UNLINK_ICON = new URL('../img/s2-icon-unlink-20-n.svg', import.meta.url).href;
+
+function blockTypeLabelForRaw(raw) {
+ if (raw === 'mixed') return 'Mixed';
+ return BLOCK_TYPE_LABELS.get(raw)
+ ?? raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+class EwSelectionToolbar extends LitElement {
+ static properties = {
+ view: { attribute: false },
+ _linkDialogOpen: { state: true },
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [styles];
+ }
+
+ get _popover() { return this.shadowRoot?.querySelector('nx-popover'); }
+
+ get _picker() { return this.shadowRoot?.querySelector('nx-picker'); }
+
+ show({ x, y }) {
+ this._popover?.show({ x, y, placement: 'above' });
+ this.requestUpdate();
+ }
+
+ hide() {
+ this._popover?.close();
+ }
+
+ get open() {
+ return this._popover?.open ?? false;
+ }
+
+ _icon(url) {
+ return html`
`;
+ }
+
+ /* ---- Block-type picker ---- */
+
+ _syncBlockTypePicker() {
+ const picker = this._picker;
+ if (!picker || !this.view) return;
+ const raw = getBlockTypePickerValue(this.view.state);
+ if (BLOCK_TYPE_LABELS.has(raw)) {
+ picker.value = raw;
+ picker.labelOverride = '';
+ } else {
+ picker.value = '';
+ picker.labelOverride = blockTypeLabelForRaw(raw);
+ }
+ }
+
+ _onBlockTypeChange(e) {
+ if (!this.view) return;
+ const cmd = COMMAND_BY_ID.get(e.detail.value);
+ if (cmd) {
+ cmd.apply(this.view);
+ this.requestUpdate();
+ this.view.focus();
+ }
+ }
+
+ /* ---- Mark / structure buttons ---- */
+
+ _onToolbarClick(e) {
+ e.preventDefault();
+ if (!this.view) return;
+ const btn = e.target instanceof Element ? e.target.closest('button') : null;
+ if (!btn || btn.disabled) return;
+
+ const { id, link } = btn.dataset;
+ if (link === 'create' || link === 'edit') {
+ this._showLinkDialog();
+ return;
+ }
+ if (link === 'remove') {
+ removeLink(this.view);
+ this.requestUpdate();
+ this.view.focus();
+ return;
+ }
+ if (id) {
+ COMMAND_BY_ID.get(id)?.apply(this.view);
+ this.requestUpdate();
+ this.view.focus();
+ }
+ }
+
+ _isCommandActive(id) {
+ if (!this.view) return false;
+ return COMMAND_BY_ID.get(id)?.active?.(this.view.state) ?? false;
+ }
+
+ _isCommandVisible(id) {
+ if (!this.view) return true;
+ const cmd = COMMAND_BY_ID.get(id);
+ return cmd?.visible ? cmd.visible(this.view.state) : true;
+ }
+
+ _isCommandDisabled(id) {
+ if (!this.view) return false;
+ const cmd = COMMAND_BY_ID.get(id);
+ return cmd?.disabled ? cmd.disabled(this.view.state) : false;
+ }
+
+ _hasLink() {
+ if (!this.view) return false;
+ return selectionHasLink(this.view.state);
+ }
+
+ /* ---- Link dialog ---- */
+
+ _showLinkDialog() {
+ if (!this.view) return;
+ this.hide();
+ this._linkDialogOpen = true;
+ }
+
+ _closeLinkDialog() {
+ this._linkDialogOpen = false;
+ this.view?.focus();
+ }
+
+ _onLinkDialogSubmit(e) {
+ e.preventDefault();
+ if (!this.view) return;
+ const form = e.target;
+ const href = form.elements['link-href'].value.trim();
+ if (!href) return;
+ const text = form.elements['link-text'].value;
+ this._closeLinkDialog();
+ applyLink(this.view, { href, text });
+ this.view.focus();
+ }
+
+ _onLinkBackdropMousedown(e) {
+ if (e.target === e.currentTarget) this._closeLinkDialog();
+ }
+
+ _onLinkBackdropKeydown(e) {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ this._closeLinkDialog();
+ }
+ }
+
+ get linkDialogOpen() { return this._linkDialogOpen ?? false; }
+
+ /* ---- Rendering ---- */
+
+ updated() {
+ this._syncBlockTypePicker();
+ }
+
+ _renderMarkButton({ id, label, icon }) {
+ const pressed = this._isCommandActive(id);
+ return html`
+
+ `;
+ }
+
+ _renderStructureButton({ id, label, icon }) {
+ const hidden = !this._isCommandVisible(id);
+ const disabled = this._isCommandDisabled(id);
+ return html`
+
+ `;
+ }
+
+ _renderLinkButtons() {
+ const hasLink = this._hasLink();
+ return html`
+
+
+
+ `;
+ }
+
+ _renderLinkDialog() {
+ if (!this._linkDialogOpen) return nothing;
+ const info = this.view ? getLinkInfoInSelection(this.view.state) : null;
+
+ let hrefVal = '';
+ let textVal = '';
+ if (info) {
+ hrefVal = info.href;
+ textVal = info.text;
+ } else if (this.view) {
+ const { from, to } = this.view.state.selection;
+ textVal = from !== to ? this.view.state.doc.textBetween(from, to) : '';
+ }
+
+ return html`
+
+ `;
+ }
+
+ render() {
+ const disabled = !this.view;
+ return html`
+
+ { e.preventDefault(); e.stopPropagation(); }}
+ @click=${(e) => this._onToolbarClick(e)}>
+
+ this._onBlockTypeChange(e)}
+ >
+
+
+ ${MARK_ITEMS.map((m) => this._renderMarkButton(m))}
+
+ ${STRUCTURE_ITEMS.map((s) => this._renderStructureButton(s))}
+
+ ${this._renderLinkButtons()}
+
+
+ ${this._renderLinkDialog()}
+ `;
+ }
+}
+
+customElements.define('ew-selection-toolbar', EwSelectionToolbar);
+
+export default EwSelectionToolbar;
diff --git a/blocks/canvas/ew-tool-panel/tool-panel.css b/blocks/canvas/ew-tool-panel/tool-panel.css
new file mode 100644
index 00000000..c0068850
--- /dev/null
+++ b/blocks/canvas/ew-tool-panel/tool-panel.css
@@ -0,0 +1,133 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ font-family: var(--s2-font-family);
+ overflow: hidden;
+}
+
+.tool-panel-header {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ min-height: 48px;
+ padding: 0 var(--s2-spacing-100);
+ border-bottom: 1px solid var(--s2-gray-200);
+}
+
+.tool-panel-header-actions {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: var(--s2-spacing-75);
+ justify-content: flex-end;
+ padding-right: var(--s2-spacing-75);
+}
+
+.tool-panel-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+ border: none;
+ border-radius: var(--s2-corner-radius-400);
+ background: transparent;
+ color: var(--s2-gray-800);
+ cursor: pointer;
+
+ img {
+ display: block;
+ width: 16px;
+ height: 16px;
+ }
+
+ &:hover {
+ background-color: var(--s2-gray-75);
+ }
+}
+
+.tool-panel-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 0;
+
+ > * {
+ flex: 1;
+ min-height: 0;
+ }
+
+ > [hidden] {
+ display: none;
+ }
+}
+
+.nx-tool-panel-editor-placeholder {
+ min-height: 120px;
+}
+
+/* Full-size experience dialog (same shell as ew-panel-extension preview). */
+.tool-panel-fullsize-dialog {
+ position: fixed;
+ inset: 0;
+ width: 90vw;
+ max-width: 1200px;
+ height: 80vh;
+ margin: auto;
+ padding: 0;
+ border: 1px solid light-dark(var(--s2-gray-300), var(--s2-gray-600));
+ border-radius: 8px;
+ background: light-dark(var(--s2-gray-50), var(--s2-gray-800));
+ color: inherit;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.tool-panel-fullsize-dialog::backdrop {
+ background: rgb(0 0 0 / 40%);
+}
+
+.tool-panel-fullsize-dialog-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid light-dark(var(--s2-gray-200), var(--s2-gray-600));
+ flex-shrink: 0;
+}
+
+.tool-panel-fullsize-dialog-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.tool-panel-fullsize-dialog-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ background: none;
+ cursor: pointer;
+ font-size: 16px;
+ color: inherit;
+}
+
+.tool-panel-fullsize-dialog-close:hover {
+ background: light-dark(var(--s2-gray-200), var(--s2-gray-600));
+}
+
+.tool-panel-fullsize-dialog-body {
+ flex: 1;
+ position: relative;
+ min-height: 0;
+ overflow: hidden;
+}
diff --git a/blocks/canvas/ew-tool-panel/tool-panel.js b/blocks/canvas/ew-tool-panel/tool-panel.js
new file mode 100644
index 00000000..5546c6c4
--- /dev/null
+++ b/blocks/canvas/ew-tool-panel/tool-panel.js
@@ -0,0 +1,208 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+await import(`${getNx()}/blocks/shared/picker/picker.js`);
+
+const style = await loadStyle(import.meta.url);
+
+const CLOSE_ICON_SRC = '/img/icons/s2-icon-splitright-20-n.svg';
+const OPEN_IN_ICON_URL = '/img/icons/s2-icon-openin-20-n.svg';
+const ACTIVE_VIEW_KEY = 'nx-tool-panel-active-view';
+
+class EwToolPanel extends LitElement {
+ static properties = {
+ views: { attribute: false },
+ activeId: { type: String },
+ _fullsizeDialogViewId: { state: true },
+ };
+
+ _loaded = {};
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ }
+
+ get _fullsizeDialogView() {
+ const id = this._fullsizeDialogViewId;
+ if (!id || !this.views) return null;
+ return this.views.find((v) => v.id === id) ?? null;
+ }
+
+ _pickerItemsFromViews() {
+ if (!this.views?.length) return [];
+ const items = [];
+ let lastSection;
+ for (const v of this.views) {
+ if (v.section && v.section !== lastSection) {
+ items.push({ section: v.section });
+ lastSection = v.section;
+ }
+ const opensExternally = v.experience === 'window' || v.experience === 'fullsize-dialog';
+ items.push({
+ value: v.id,
+ label: v.label,
+ ...(opensExternally && {
+ action: true,
+ trailingIcon: OPEN_IN_ICON_URL,
+ ariaLabel: v.experience === 'window'
+ ? `${v.label} (opens in new tab)`
+ : `${v.label} (opens in dialog)`,
+ }),
+ });
+ }
+ return items;
+ }
+
+ _pruneLoadedViews() {
+ const ids = new Set(this.views.map((v) => v.id));
+ Object.keys(this._loaded).forEach((id) => {
+ if (!ids.has(id)) {
+ this._loaded[id].remove();
+ delete this._loaded[id];
+ }
+ });
+ }
+
+ async updated(changed) {
+ if (changed.has('views')) await this._onViewsChange();
+ if (changed.has('activeId')) {
+ if (this.activeId) {
+ try { sessionStorage.setItem(ACTIVE_VIEW_KEY, this.activeId); } catch { /* ignore */ }
+ }
+ this._syncContent();
+ this._syncHeaderActions();
+ }
+ if (changed.has('_fullsizeDialogViewId') && this._fullsizeDialogViewId) {
+ await this._mountDialog();
+ }
+ }
+
+ async _onViewsChange() {
+ if (!this.views?.length) {
+ this._closeDialog();
+ this.activeId = undefined;
+ this._loaded = {};
+ this.shadowRoot.querySelector('.tool-panel-content').replaceChildren();
+ return;
+ }
+
+ this._pruneLoadedViews();
+ const ids = new Set(this.views.map((v) => v.id));
+
+ if (this._fullsizeDialogViewId && !ids.has(this._fullsizeDialogViewId)) {
+ this._closeDialog();
+ }
+
+ if (!this.activeId || !ids.has(this.activeId)) {
+ const stored = sessionStorage.getItem(ACTIVE_VIEW_KEY);
+ await this.showView(stored && ids.has(stored) ? stored : this.views[0].id);
+ }
+ }
+
+ async _mountDialog() {
+ await this.updateComplete;
+ const dialog = this.shadowRoot.querySelector('.tool-panel-fullsize-dialog');
+ const body = dialog.querySelector('.tool-panel-fullsize-dialog-body');
+ const viewId = this._fullsizeDialogViewId;
+ if (body.dataset.mountedFor === viewId) return;
+ body.innerHTML = '';
+ body.dataset.mountedFor = viewId;
+ if (!dialog.open) dialog.showModal();
+ const view = this.views.find((v) => v.id === viewId);
+ await view.loadModal(body, () => dialog.close());
+ }
+
+ _closeDialog() {
+ const dialog = this.shadowRoot.querySelector('.tool-panel-fullsize-dialog');
+ if (dialog?.open) {
+ dialog.close();
+ } else {
+ this._fullsizeDialogViewId = undefined;
+ }
+ }
+
+ async showView(id) {
+ const consumer = this.views.find((c) => c.id === id);
+ if (!consumer) return;
+ if (consumer.experience === 'window') {
+ window.open(
+ new URL(consumer.sources[0], window.location.href).href,
+ '_blank',
+ 'noopener,noreferrer',
+ );
+ return;
+ }
+ if (consumer.experience === 'fullsize-dialog') {
+ this._fullsizeDialogViewId = id;
+ return;
+ }
+ if (!this._loaded[id]) {
+ this._loaded[id] = await consumer.load();
+ }
+ this.activeId = id;
+ }
+
+ _syncContent() {
+ const content = this.shadowRoot.querySelector('.tool-panel-content');
+ Object.entries(this._loaded).forEach(([id, el]) => {
+ el.hidden = id !== this.activeId;
+ if (id === this.activeId && !content.contains(el)) content.append(el);
+ });
+ }
+
+ _syncHeaderActions() {
+ const zone = this.shadowRoot.querySelector('.tool-panel-header-actions');
+ zone.textContent = '';
+ const consumer = this.views.find((c) => c.id === this.activeId);
+ if (!consumer?.firstParty) return;
+ const actions = this._loaded[this.activeId]?.getHeaderActions?.();
+ if (actions) zone.append(actions);
+ }
+
+ _close() {
+ this.dispatchEvent(new CustomEvent('nx-panel-close', { bubbles: true, composed: true }));
+ }
+
+ _onFullsizeDialogClose() {
+ this._fullsizeDialogViewId = undefined;
+ }
+
+ render() {
+ const items = this._pickerItemsFromViews();
+ const dialogTitle = this._fullsizeDialogView?.label ?? 'Extension';
+
+ return html`
+
+
+ ${this._fullsizeDialogViewId ? html`
+
+ ` : nothing}
+ `;
+ }
+}
+
+customElements.define('ew-tool-panel', EwToolPanel);
diff --git a/blocks/canvas/img/s2-icon-blockcode-20-n.svg b/blocks/canvas/img/s2-icon-blockcode-20-n.svg
new file mode 100644
index 00000000..f1b75494
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-blockcode-20-n.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/blocks/canvas/img/s2-icon-blockquote-20-n.svg b/blocks/canvas/img/s2-icon-blockquote-20-n.svg
new file mode 100644
index 00000000..9d3034cf
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-blockquote-20-n.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/blocks/canvas/img/s2-icon-cclibrary-20-n.svg b/blocks/canvas/img/s2-icon-cclibrary-20-n.svg
new file mode 100644
index 00000000..e0b7ed27
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-cclibrary-20-n.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-code-20-n.svg b/blocks/canvas/img/s2-icon-code-20-n.svg
new file mode 100644
index 00000000..625b4d42
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-code-20-n.svg
@@ -0,0 +1,5 @@
+
diff --git a/blocks/canvas/img/s2-icon-gridcompare-20-n.svg b/blocks/canvas/img/s2-icon-gridcompare-20-n.svg
new file mode 100644
index 00000000..ccf76e06
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-gridcompare-20-n.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-heading1-20-n.svg b/blocks/canvas/img/s2-icon-heading1-20-n.svg
new file mode 100644
index 00000000..266cff49
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-heading1-20-n.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-heading2-20-n.svg b/blocks/canvas/img/s2-icon-heading2-20-n.svg
new file mode 100644
index 00000000..6453ca1d
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-heading2-20-n.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-heading3-20-n.svg b/blocks/canvas/img/s2-icon-heading3-20-n.svg
new file mode 100644
index 00000000..9a2d4448
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-heading3-20-n.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-heading4-20-n.svg b/blocks/canvas/img/s2-icon-heading4-20-n.svg
new file mode 100644
index 00000000..b2777248
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-heading4-20-n.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-heading5-20-n.svg b/blocks/canvas/img/s2-icon-heading5-20-n.svg
new file mode 100644
index 00000000..802c6554
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-heading5-20-n.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-heading6-20-n.svg b/blocks/canvas/img/s2-icon-heading6-20-n.svg
new file mode 100644
index 00000000..dcc49ff4
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-heading6-20-n.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-link-20-n.svg b/blocks/canvas/img/s2-icon-link-20-n.svg
new file mode 100644
index 00000000..2f54b8a4
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-link-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/blocks/canvas/img/s2-icon-listbulleted-20-n.svg b/blocks/canvas/img/s2-icon-listbulleted-20-n.svg
new file mode 100644
index 00000000..8213dbc8
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-listbulleted-20-n.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-listnumbered-20-n.svg b/blocks/canvas/img/s2-icon-listnumbered-20-n.svg
new file mode 100644
index 00000000..8cdb8d6c
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-listnumbered-20-n.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-rail-20-n.svg b/blocks/canvas/img/s2-icon-rail-20-n.svg
new file mode 100644
index 00000000..b70a99e2
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-rail-20-n.svg
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/blocks/canvas/img/s2-icon-separator-20-n.svg b/blocks/canvas/img/s2-icon-separator-20-n.svg
new file mode 100644
index 00000000..c357171f
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-separator-20-n.svg
@@ -0,0 +1,7 @@
+
diff --git a/blocks/canvas/img/s2-icon-splitleft-20-n.svg b/blocks/canvas/img/s2-icon-splitleft-20-n.svg
new file mode 100644
index 00000000..3c51c414
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-splitleft-20-n.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-splitright-20-n.svg b/blocks/canvas/img/s2-icon-splitright-20-n.svg
new file mode 100644
index 00000000..68810c74
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-splitright-20-n.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-tableadd-20-n.svg b/blocks/canvas/img/s2-icon-tableadd-20-n.svg
new file mode 100644
index 00000000..31a90ff8
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-tableadd-20-n.svg
@@ -0,0 +1,6 @@
+
diff --git a/blocks/canvas/img/s2-icon-tagbold-20-n.svg b/blocks/canvas/img/s2-icon-tagbold-20-n.svg
new file mode 100644
index 00000000..ded1b999
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-tagbold-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/blocks/canvas/img/s2-icon-tagitalic-20-n.svg b/blocks/canvas/img/s2-icon-tagitalic-20-n.svg
new file mode 100644
index 00000000..436ab3f9
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-tagitalic-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/blocks/canvas/img/s2-icon-tagstrikethrough-20-n.svg b/blocks/canvas/img/s2-icon-tagstrikethrough-20-n.svg
new file mode 100644
index 00000000..e3ba30f9
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-tagstrikethrough-20-n.svg
@@ -0,0 +1,4 @@
+
diff --git a/blocks/canvas/img/s2-icon-tagunderline-20-n.svg b/blocks/canvas/img/s2-icon-tagunderline-20-n.svg
new file mode 100644
index 00000000..9022c1ed
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-tagunderline-20-n.svg
@@ -0,0 +1,4 @@
+
diff --git a/blocks/canvas/img/s2-icon-textindentdecrease-20-n.svg b/blocks/canvas/img/s2-icon-textindentdecrease-20-n.svg
new file mode 100644
index 00000000..d3196ded
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-textindentdecrease-20-n.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-textindentincrease-20-n.svg b/blocks/canvas/img/s2-icon-textindentincrease-20-n.svg
new file mode 100644
index 00000000..966cf0f6
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-textindentincrease-20-n.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/blocks/canvas/img/s2-icon-unlink-20-n.svg b/blocks/canvas/img/s2-icon-unlink-20-n.svg
new file mode 100644
index 00000000..b63fea9f
--- /dev/null
+++ b/blocks/canvas/img/s2-icon-unlink-20-n.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/blocks/canvas/utils/tree-nav.js b/blocks/canvas/utils/tree-nav.js
new file mode 100644
index 00000000..585cde9a
--- /dev/null
+++ b/blocks/canvas/utils/tree-nav.js
@@ -0,0 +1,35 @@
+export function treeEnsureTabStop(shadowRoot) {
+ const items = [...shadowRoot.querySelectorAll('[role="treeitem"]')];
+ if (items.length && !items.some((el) => el.tabIndex === 0)) items[0].tabIndex = 0;
+}
+
+export function treeFocusIn(e, shadowRoot) {
+ const item = e.target.closest('[role="treeitem"]');
+ if (!item) return;
+ shadowRoot.querySelectorAll('[role="treeitem"]').forEach((el) => {
+ el.tabIndex = el === item ? 0 : -1;
+ });
+}
+
+export function treeKeydown(e, shadowRoot) {
+ const items = [...shadowRoot.querySelectorAll('[role="treeitem"]')];
+ if (!items.length) return;
+ const idx = items.indexOf(shadowRoot.activeElement);
+ if (idx === -1) return;
+
+ let next = idx;
+ switch (e.key) {
+ case 'ArrowDown': next = Math.min(idx + 1, items.length - 1); break;
+ case 'ArrowUp': next = Math.max(idx - 1, 0); break;
+ case 'Home': next = 0; break;
+ case 'End': next = items.length - 1; break;
+ default: return;
+ }
+
+ if (next !== idx) {
+ e.preventDefault();
+ items[idx].tabIndex = -1;
+ items[next].tabIndex = 0;
+ items[next].focus();
+ }
+}
diff --git a/blocks/edit/da-library/helpers/helpers.js b/blocks/edit/da-library/helpers/helpers.js
index 518b0ba0..18033486 100644
--- a/blocks/edit/da-library/helpers/helpers.js
+++ b/blocks/edit/da-library/helpers/helpers.js
@@ -48,7 +48,7 @@ export async function getItems(sources, format) {
const items = [];
for (const source of sources) {
try {
- const resp = await daFetch(source);
+ const resp = await daFetch(source, { noRedirect: true });
const json = await resp.json();
if (json.data) {
items.push(...formatData(json.data, format));
diff --git a/blocks/inventory/action-bar/action-bar.css b/blocks/inventory/action-bar/action-bar.css
new file mode 100644
index 00000000..064e88c1
--- /dev/null
+++ b/blocks/inventory/action-bar/action-bar.css
@@ -0,0 +1,88 @@
+:host {
+ display: flex;
+ align-items: center;
+ min-height: var(--browse-title-bar-height, var(--s2-spacing-500));
+ padding: 0 var(--s2-spacing-200);
+ box-sizing: border-box;
+ border-radius: var(--s2-corner-radius-500);
+ background-color: var(--s2-blue-900);
+ color: light-dark(var(--s2-gray-25), #fff);
+ font-family: var(--s2-font-family);
+}
+
+.left {
+ display: flex;
+ align-items: center;
+ gap: var(--s2-spacing-100);
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--s2-spacing-200);
+ margin-inline-start: auto;
+}
+
+.label {
+ font-size: var(--s2-component-s-bold-font-size);
+ white-space: nowrap;
+}
+
+.action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s2-spacing-75);
+ height: 24px;
+ padding: 0 var(--s2-spacing-150);
+ border: none;
+ border-radius: var(--s2-corner-radius-400);
+ background: inherit;
+ color: inherit;
+ font-family: var(--s2-font-family);
+ font-size: var(--s2-component-s-bold-font-size);
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+ }
+
+ & svg {
+ display: block;
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ }
+
+ & :is(path, circle, ellipse, line, polyline, polygon) {
+ fill: currentcolor;
+ }
+}
+
+.close-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: none;
+ border-radius: var(--s2-corner-radius-400);
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+
+ &:hover {
+ background-color: rgb(255 255 255 / 15%);
+ }
+
+ & svg {
+ display: block;
+ width: 16px;
+ height: 16px;
+ }
+
+ & :is(path, circle, rect, ellipse, line, polyline, polygon) {
+ fill: currentcolor;
+ }
+}
diff --git a/blocks/inventory/action-bar/action-bar.js b/blocks/inventory/action-bar/action-bar.js
new file mode 100644
index 00000000..9e7719fa
--- /dev/null
+++ b/blocks/inventory/action-bar/action-bar.js
@@ -0,0 +1,112 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { isFolder } from '../utils.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+const { loadHrefSvg, ICONS_BASE } = await import(`${getNx()}/utils/svg.js`);
+
+const ICON_BASE = new URL('../../../img/icons/', import.meta.url).href;
+const styles = await loadStyle(import.meta.url);
+const [closeIcon, previewIcon, publishIcon, shareIcon, deleteIcon, renameIcon] = await Promise.all([
+ loadHrefSvg(`${ICON_BASE}s2-icon-close-20-n.svg`),
+ loadHrefSvg(`${ICON_BASE}s2-icon-preview-20-n.svg`),
+ loadHrefSvg(`${ICON_BASE}s2-icon-publish-20-n.svg`),
+ loadHrefSvg(`${ICON_BASE}s2-icon-share-20-n.svg`),
+ loadHrefSvg(`${ICON_BASE}s2-icon-delete-20-n.svg`),
+ loadHrefSvg(`${ICONS_BASE}S2_Icon_Edit_20_N.svg`),
+]);
+
+class NxBrowseActionBar extends LitElement {
+ static properties = {
+ selected: { type: Array },
+ isDisabled: { type: Boolean, attribute: false },
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [styles];
+ }
+
+ get _count() {
+ return this.selected?.length ?? 0;
+ }
+
+ _onClear() {
+ this.dispatchEvent(new CustomEvent('nx-action-bar-clear', {
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ _onAction(action) {
+ this.dispatchEvent(new CustomEvent('nx-browse-selection-action', {
+ detail: { action },
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ render() {
+ const count = this._count;
+ const singleFile = count === 1 && !isFolder(this.selected[0].item);
+
+ return html`
+
+
+
+ ${count} item${count !== 1 ? 's' : ''} selected
+
+
+
+ ${count === 1 ? html`
+
+ ` : nothing}
+ ${singleFile ? html`
+
+
+ ` : nothing}
+
+
+
+ `;
+ }
+}
+
+if (!customElements.get('nx-browse-action-bar')) {
+ customElements.define('nx-browse-action-bar', NxBrowseActionBar);
+}
diff --git a/blocks/inventory/browse-api.js b/blocks/inventory/browse-api.js
new file mode 100644
index 00000000..9001215f
--- /dev/null
+++ b/blocks/inventory/browse-api.js
@@ -0,0 +1,85 @@
+import { daFetch } from '../shared/utils.js';
+import { AEM_ORIGIN, DA_ORIGIN, getLivePreviewUrl } from '../shared/constants.js';
+
+export async function saveToAem(path, action) {
+ const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
+ const orgSlashIndex = normalizedPath.indexOf('/');
+ if (orgSlashIndex < 1) {
+ return { error: 'Invalid path for AEM', status: 0 };
+ }
+ const siteSlashIndex = normalizedPath.indexOf('/', orgSlashIndex + 1);
+ if (siteSlashIndex < orgSlashIndex + 1) {
+ return { error: 'Invalid path for AEM', status: 0 };
+ }
+ const owner = normalizedPath.slice(0, orgSlashIndex).toLowerCase();
+ const repo = normalizedPath.slice(orgSlashIndex + 1, siteSlashIndex).toLowerCase();
+ const aemPath = normalizedPath.slice(siteSlashIndex + 1);
+ const requestUrl = `${AEM_ORIGIN}/${action}/${owner}/${repo}/main/${aemPath}`;
+ const response = await daFetch(requestUrl, { method: 'POST' });
+ if (!response.ok) {
+ const headerError = response.headers.get('x-error') || response.statusText || 'AEM request failed';
+ return { error: headerError, status: response.status };
+ }
+ try {
+ const json = await response.json();
+ return { json };
+ } catch {
+ return { json: {} };
+ }
+}
+
+export async function deploy(sourcePath, action) {
+ const phases = action === 'publish' ? ['preview', 'live'] : ['preview'];
+ const openedUrls = [];
+ for (const phase of phases) {
+ const result = await saveToAem(sourcePath, phase);
+ if ('error' in result) return { ok: false };
+ if (phase === 'preview' && action === 'preview') {
+ const url = result.json?.preview?.url;
+ if (url) openedUrls.push(url);
+ } else if (phase === 'live') {
+ const url = result.json?.live?.url;
+ if (url) openedUrls.push(url);
+ }
+ }
+ return { ok: true, openedUrls };
+}
+
+export function getItemPreviewUrl(item) {
+ const path = item?.path || '';
+ const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
+ const orgSlashIndex = normalizedPath.indexOf('/');
+ if (orgSlashIndex < 1) return null;
+ const siteSlashIndex = normalizedPath.indexOf('/', orgSlashIndex + 1);
+ if (siteSlashIndex < orgSlashIndex + 1) return null;
+ const owner = normalizedPath.slice(0, orgSlashIndex).toLowerCase();
+ const repo = normalizedPath.slice(orgSlashIndex + 1, siteSlashIndex).toLowerCase();
+ const aemPath = normalizedPath.slice(siteSlashIndex + 1);
+ const base = getLivePreviewUrl(owner, repo);
+ if (!aemPath) return base;
+ const cleanPath = item.ext === 'html' ? aemPath.replace(/\.html$/, '') : aemPath;
+ return `${base}/${cleanPath}`;
+}
+
+export async function renameItem(item, newName) {
+ const { path, name } = item;
+ const idx = path.lastIndexOf(name);
+ if (idx < 0) return { ok: false, error: 'Could not determine new path' };
+ const newPath = `${path.slice(0, idx)}${newName}${path.slice(idx + name.length)}`;
+ const body = new FormData();
+ body.append('destination', newPath);
+ const response = await daFetch(`${DA_ORIGIN}/move${path}`, { method: 'POST', body });
+ if (response.status === 204 || response.ok) return { ok: true, newPath };
+ const error = response.headers?.get('x-error') || response.statusText || 'Rename failed';
+ return { ok: false, error };
+}
+
+export async function deleteSourcePath(path) {
+ if (!path) return { ok: false, error: 'Missing path' };
+ const response = await daFetch(`${DA_ORIGIN}/source${path}`, { method: 'DELETE' });
+ if (!response.ok) {
+ const error = response.headers.get('x-error') || response.statusText || 'Delete failed';
+ return { ok: false, status: response.status, error };
+ }
+ return { ok: true };
+}
diff --git a/blocks/inventory/delete/delete.css b/blocks/inventory/delete/delete.css
new file mode 100644
index 00000000..791ac9d0
--- /dev/null
+++ b/blocks/inventory/delete/delete.css
@@ -0,0 +1,77 @@
+:host {
+ display: contents;
+}
+
+.list {
+ margin: 0;
+ padding: 0 0 0 var(--s2-spacing-300);
+ list-style: disc;
+ list-style-position: outside;
+ max-height: 8rem;
+ overflow-y: auto;
+ overflow-wrap: anywhere;
+ line-height: 1.43;
+}
+
+.list li + li {
+ margin-top: var(--s2-spacing-100);
+}
+
+.hint {
+ margin: var(--s2-spacing-200) 0 0;
+ color: var(--s2-gray-700);
+ font-size: var(--s2-body-size-xs);
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s2-spacing-75);
+ height: 32px;
+ padding: 0 var(--s2-spacing-300);
+ border: 1px solid transparent;
+ border-radius: var(--s2-corner-radius-400);
+ font-family: var(--s2-font-family);
+ font-size: var(--s2-component-m-bold-font-size);
+ font-weight: var(--s2-component-m-bold-font-weight);
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+ }
+}
+
+.btn-secondary {
+ background: transparent;
+ border-color: var(--s2-gray-300);
+ color: var(--s2-gray-800);
+
+ &:hover:not(:disabled) {
+ background: var(--s2-gray-75);
+ }
+}
+
+.btn-danger {
+ background: var(--s2-red-900);
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: var(--s2-red-1000);
+ }
+}
+
+.spinner {
+ display: block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgb(255 255 255 / 30%);
+ border-top-color: currentcolor;
+ border-radius: 50%;
+ animation: delete-spin 0.7s linear infinite;
+ flex-shrink: 0;
+}
+
+@keyframes delete-spin {
+ to { transform: rotate(360deg); }
+}
diff --git a/blocks/inventory/delete/delete.js b/blocks/inventory/delete/delete.js
new file mode 100644
index 00000000..a67c37d7
--- /dev/null
+++ b/blocks/inventory/delete/delete.js
@@ -0,0 +1,102 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { deleteSourcePath } from '../browse-api.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+await import(`${getNx()}/blocks/shared/dialog/dialog.js`);
+const styles = await loadStyle(import.meta.url);
+
+class NxInventoryDeleteDialog extends LitElement {
+ static properties = {
+ selectedRows: { type: Array },
+ _isPending: { state: true },
+ };
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [styles];
+ }
+
+ _emitComplete(detail = {}) {
+ this.dispatchEvent(new CustomEvent('nx-browse-action-complete', {
+ detail,
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ _handleCancel = () => { this._emitComplete(); };
+
+ _handleClose = () => { this._emitComplete(); };
+
+ _handleConfirm = async () => {
+ const { selectedRows } = this;
+ if (!selectedRows?.length) {
+ this._emitComplete();
+ return;
+ }
+
+ this._isPending = true;
+ try {
+ for (const item of selectedRows) {
+ const result = await deleteSourcePath(item.path);
+ if (!result.ok) {
+ this._emitComplete({ message: result.error || 'Delete failed' });
+ return;
+ }
+ }
+ this._emitComplete({ success: true });
+ } catch {
+ this._emitComplete({ message: 'An unexpected error occurred.' });
+ } finally {
+ this._isPending = false;
+ }
+ };
+
+ render() {
+ const selectedRows = this.selectedRows ?? [];
+ if (!selectedRows.length) return nothing;
+
+ const count = selectedRows.length;
+ const itemWord = count === 1 ? 'item' : 'items';
+ const lines = selectedRows.map((item) => item.path).slice(0, 5);
+ const more = count > 5 ? count - 5 : 0;
+
+ return html`
+
+
+
+ ${lines.map((path) => html`- ${path}
`)}
+
+ ${more > 0 ? html`
…and ${more} more
` : nothing}
+
+
+
+
+ `;
+ }
+}
+
+if (!customElements.get('nx-inventory-delete-dialog')) {
+ customElements.define('nx-inventory-delete-dialog', NxInventoryDeleteDialog);
+}
diff --git a/blocks/inventory/inventory.css b/blocks/inventory/inventory.css
new file mode 100644
index 00000000..a0d10b88
--- /dev/null
+++ b/blocks/inventory/inventory.css
@@ -0,0 +1,133 @@
+:host {
+ --browse-sheet-max-width: 1024px;
+
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ height: calc(100dvh - var(--s2-nav-height));
+ min-width: 0;
+ padding: var(--s2-spacing-200) var(--s2-spacing-300);
+ overflow: hidden;
+ font-family: var(--s2-font-family);
+ font-size: var(--s2-body-size-s);
+ line-height: var(--s2-body-line-height);
+ color: var(--s2-gray-800);
+ background-color: var(--s2-gray-25);
+ overscroll-behavior: contain;
+
+ nx-browse-list {
+ flex: 1 1 0;
+ min-height: 0;
+ min-width: 0;
+ }
+
+ .browse-bar {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ height: 40px;
+ padding: 0 var(--s2-spacing-100);
+
+ path {
+ fill: currentcolor;
+ }
+ }
+
+ .browse-panel-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: var(--s2-corner-radius-400);
+ background: transparent;
+ color: var(--s2-gray-800);
+ cursor: pointer;
+
+ img {
+ display: block;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ }
+
+ &:hover {
+ background-color: var(--s2-gray-75);
+ }
+ }
+
+ .browse-hint {
+ margin: 0 var(--s2-spacing-300);
+ padding: var(--s2-spacing-200);
+ border-radius: var(--s2-corner-radius-300);
+ background-color: var(--s2-gray-75);
+ }
+
+ .browse-hint-title {
+ margin: 0 0 var(--s2-spacing-100);
+ font-weight: var(--s2-component-s-medium-font-weight);
+ font-size: var(--s2-component-s-medium-font-size);
+ line-height: var(--s2-component-s-medium-line-height);
+ color: var(--s2-gray-900);
+ }
+
+ .browse-hint-detail {
+ margin: 0;
+ color: var(--s2-gray-700);
+ }
+
+ .browse-hint-error {
+ background-color: var(--s2-red-200);
+ }
+
+ .browse-hint-error .browse-hint-title {
+ color: var(--s2-red-1100);
+ }
+
+ .browse-hint-error .browse-hint-detail {
+ color: var(--s2-red-1000);
+ }
+
+ .browse-header {
+ --browse-title-bar-height: var(--s2-spacing-500);
+
+ position: relative;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: var(--browse-sheet-max-width);
+ margin: 0 auto var(--s2-spacing-300);
+
+ nx-browse-action-bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--browse-title-bar-height);
+ }
+ }
+
+ .browse-title-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--s2-spacing-100);
+ box-sizing: border-box;
+ min-width: 0;
+ height: var(--browse-title-bar-height);
+ margin-bottom: var(--s2-spacing-100);
+ }
+
+ .browse-title {
+ flex: 1 1 auto;
+ margin: 0;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--s2-heading-size-m);
+ line-height: 26px;
+ font-weight: var(--s2-component-m-bold-font-weight);
+ color: var(--s2-gray-900);
+ }
+}
diff --git a/blocks/inventory/inventory.js b/blocks/inventory/inventory.js
new file mode 100644
index 00000000..0bacc0ef
--- /dev/null
+++ b/blocks/inventory/inventory.js
@@ -0,0 +1,350 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../scripts/utils.js';
+import { listFolder, itemHashPath } from '../shared/daFiles.js';
+import {
+ contextToPathContext,
+ entryTypeFromExtension,
+ isFolder,
+ RESOURCE_TYPE,
+} from './utils.js';
+import './list/list.js';
+import './action-bar/action-bar.js';
+import './delete/delete.js';
+import { deploy, getItemPreviewUrl, renameItem } from './browse-api.js';
+
+const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`);
+const { showToast, VARIANT_ERROR } = await import(`${getNx()}/blocks/shared/toast/toast.js`);
+const { getPanelStore, openPanel } = await import(`${getNx()}/utils/panel.js`);
+
+await import(`${getNx()}/blocks/shared/breadcrumb/breadcrumb.js`);
+
+const styles = await loadStyle(import.meta.url);
+const PANEL_ICON_SRC = '/blocks/canvas/img/s2-icon-splitleft-20-n.svg';
+
+const documentLayoutStyles = await loadStyle(
+ new URL('overrides.css', import.meta.url).href,
+);
+document.adoptedStyleSheets = [...document.adoptedStyleSheets, documentLayoutStyles];
+
+class NxBrowse extends LitElement {
+ static properties = {
+ _items: { state: true },
+ _listError: { state: true },
+ _selectedItems: { state: true },
+ _pendingAction: { state: true },
+ _activeAction: { state: true },
+ };
+
+ set context(value) {
+ this._explicitContext = true;
+ this._context = value;
+ this.requestUpdate();
+ if (this.isConnected) {
+ this._syncList();
+ }
+ }
+
+ _openPanel(position) {
+ this.dispatchEvent(new CustomEvent('nx-browse-open-panel', {
+ bubbles: true,
+ composed: true,
+ detail: { position },
+ }));
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [styles];
+ this._unsubscribeHash = hashChange.subscribe((hashState) => {
+ if (!this._explicitContext) {
+ this._context = hashState;
+ this._syncList();
+ }
+ });
+ if (this._explicitContext && this._context) {
+ this._syncList();
+ }
+ }
+
+ disconnectedCallback() {
+ this._unsubscribeHash?.();
+ super.disconnectedCallback();
+ }
+
+ get _pathContext() {
+ return contextToPathContext(this._context);
+ }
+
+ async _syncList() {
+ const ctx = this._pathContext;
+ if (!ctx) {
+ this._items = undefined;
+ this._listError = undefined;
+ this.requestUpdate();
+ return;
+ }
+
+ const { fullpath } = ctx;
+
+ const result = await listFolder(fullpath);
+
+ if ('error' in result) {
+ this._items = undefined;
+ this._listError = result.error;
+ } else {
+ this._listError = undefined;
+ this._items = result;
+ }
+ this.requestUpdate();
+ }
+
+ _onSelectionChange(event) {
+ this._selectedItems = event.detail?.selected ?? [];
+ }
+
+ _clearSelection() {
+ this.shadowRoot.querySelector('nx-browse-list')?.clearSelection();
+ }
+
+ _onSelectionAction(event) {
+ const { action } = event.detail || {};
+ if (!action) return;
+ if (action === 'rename') {
+ const { key, item } = this._selectedItems[0];
+ this._activeAction = { type: 'rename', key, item };
+ return;
+ }
+ if (action === 'delete') {
+ this._activeAction = { type: 'delete', selectedRows: this._selectedItems.map(({ item }) => item) };
+ return;
+ }
+ if (action === 'copyLink') {
+ this._onCopyLink();
+ return;
+ }
+ this._onDeploy(action);
+ }
+
+ async _onRename(event) {
+ const { item, newName } = event.detail;
+ this._activeAction = null;
+ const { ok, error } = await renameItem(item, newName);
+ if (ok) {
+ this._clearSelection();
+ this._syncList();
+ } else {
+ showToast({ text: error ?? 'Rename failed', variant: VARIANT_ERROR });
+ }
+ }
+
+ _onRenameCancel() {
+ this._activeAction = null;
+ }
+
+ async _onCopyLink() {
+ const urls = (this._selectedItems ?? [])
+ .map(({ item }) => getItemPreviewUrl(item))
+ .filter(Boolean);
+ if (!urls.length) return;
+ try {
+ await navigator.clipboard.writeText(urls.join('\n'));
+ const count = urls.length;
+ showToast({ text: count === 1 ? 'Link copied' : `${count} links copied` });
+ } catch {
+ showToast({ text: 'Could not copy to clipboard', variant: VARIANT_ERROR });
+ }
+ }
+
+ async _onDeploy(action) {
+ if (this._pendingAction) return;
+ this._pendingAction = action;
+ const { item } = this._selectedItems[0];
+ const { ok, openedUrls } = await deploy(item.path, action);
+ this._pendingAction = null;
+ if (ok) openedUrls.forEach((url) => window.open(url, url));
+ }
+
+ _onActionComplete(event) {
+ const { success } = event.detail || {};
+ this._activeAction = null;
+ if (success) {
+ this._clearSelection();
+ this._syncList();
+ }
+ }
+
+ _onBrowseActivate(event) {
+ const { pathKey, item, shiftKey, ctrlKey } = event.detail || {};
+ if (!item) return;
+
+ if (isFolder(item)) {
+ window.location.hash = `#/${pathKey}`;
+ return;
+ }
+
+ const url = new URL(window.location.href);
+ const entryType = entryTypeFromExtension(item.ext);
+
+ if (entryType === RESOURCE_TYPE.document) {
+ url.pathname = '/canvas';
+ url.hash = `#/${itemHashPath(item)}`;
+ if (ctrlKey) {
+ window.open(url.href, '_blank');
+ } else if (shiftKey) {
+ window.open(url.href, '_blank', 'noopener,noreferrer');
+ } else {
+ window.location.assign(url.href);
+ }
+ return;
+ } else if (entryType === RESOURCE_TYPE.sheet) {
+ url.pathname = '/sheet';
+ url.hash = `#/${item.path.slice(1, -(item.ext.length + 1))}`;
+ } else {
+ url.pathname = '/media';
+ url.hash = `#${item.path}`;
+ }
+
+ url.search = '';
+ window.open(url.href, '_blank', 'noopener,noreferrer');
+ }
+
+ render() {
+ const ctx = this._pathContext;
+
+ const bar = html`
+
+
+
+ `;
+
+ if (!ctx) {
+ return html`
+ ${bar}
+
+
Nothing to show here yet
+
+ Choose a site or folder from your workspace to see files in this list.
+
+
+ `;
+ }
+
+ const title = (ctx.pathSegments.at(-1) ?? '').split(/[?#]/)[0];
+
+ if (!this._listError && this._items === undefined) {
+ return bar;
+ }
+
+ const header = html`
+
+ `;
+
+ if (this._listError) {
+ return html`
+ ${bar}
+ ${header}
+
+
Could not load this folder
+
${this._listError}
+
+ `;
+ }
+
+ const currentPathKey = ctx.pathSegments.join('/');
+
+ return html`
+ ${bar}
+ ${header}
+
+ ${this._activeAction?.type === 'delete' ? html`
+
+ ` : nothing}
+ `;
+ }
+}
+
+if (!customElements.get('nx-browse')) {
+ customElements.define('nx-browse', NxBrowse);
+}
+
+export default function decorate(block) {
+ block.textContent = '';
+ const browse = document.createElement('nx-browse');
+ block.append(browse);
+
+ const openBrowseChatPanel = () => {
+ const store = getPanelStore();
+ const width = store.before?.width ?? '400px';
+ openPanel({
+ position: 'before',
+ width,
+ getContent: async () => {
+ await import(`${getNx()}/blocks/chat/chat.js`);
+ return document.createElement('nx-chat');
+ },
+ });
+ };
+
+ browse.addEventListener('nx-browse-open-panel', (e) => {
+ if (e.detail.position === 'before') openBrowseChatPanel();
+ });
+
+ let prevKeys = new Set();
+ browse.addEventListener('nx-browse-selection-change', ({ detail }) => {
+ const selected = detail?.selected ?? [];
+ const nextKeys = new Set(selected.map(({ key }) => key));
+ for (const key of prevKeys) {
+ if (!nextKeys.has(key)) {
+ document.dispatchEvent(new CustomEvent('nx-add-to-chat', { detail: { key: `browse-${key}` } }));
+ }
+ }
+ for (const { key, item } of selected) {
+ if (!prevKeys.has(key)) {
+ const label = item.ext ? `${item.name}.${item.ext}` : item.name;
+ document.dispatchEvent(new CustomEvent('nx-add-to-chat', {
+ detail: {
+ key: `browse-${key}`,
+ id: key,
+ label,
+ blockName: label,
+ innerText: `Selected repository path: ${key}`,
+ },
+ }));
+ }
+ }
+ prevKeys = nextKeys;
+ });
+
+ const store = getPanelStore();
+ if (store.before) openBrowseChatPanel();
+}
diff --git a/blocks/inventory/list/format.js b/blocks/inventory/list/format.js
new file mode 100644
index 00000000..94818da6
--- /dev/null
+++ b/blocks/inventory/list/format.js
@@ -0,0 +1,66 @@
+const TIME_FORMAT_OPTIONS = { hour: 'numeric', minute: '2-digit' };
+
+function parseTimestamp(timestampRaw) {
+ if (timestampRaw == null || timestampRaw === '') return null;
+ const parsedDate = new Date(timestampRaw);
+ return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
+}
+
+function formatAbsolute(dateInstant, referenceNow = new Date()) {
+ const timeSegment = dateInstant.toLocaleTimeString(undefined, TIME_FORMAT_OPTIONS);
+ if (dateInstant.getFullYear() === referenceNow.getFullYear()) {
+ const dateSegment = dateInstant.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ });
+ return `${dateSegment}, ${timeSegment}`;
+ }
+ const dateSegment = dateInstant.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ return `${dateSegment}, ${timeSegment}`;
+}
+
+function formatRelative(dateInstant, referenceNow = new Date()) {
+ const elapsedMilliseconds = referenceNow - dateInstant;
+ const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
+ const eventCalendarDay = dateInstant.toDateString();
+ const referenceCalendarDay = referenceNow.toDateString();
+
+ let displayLabel;
+ if (eventCalendarDay === referenceCalendarDay) {
+ const elapsedSeconds = Math.floor(elapsedMilliseconds / 1000);
+ if (elapsedSeconds < 60) displayLabel = relativeTimeFormatter.format(0, 'second');
+ else {
+ const elapsedMinutes = Math.floor(elapsedSeconds / 60);
+ if (elapsedMinutes < 60) displayLabel = relativeTimeFormatter.format(-elapsedMinutes, 'minute');
+ else {
+ const elapsedHours = Math.floor(elapsedMinutes / 60);
+ displayLabel = relativeTimeFormatter.format(-elapsedHours, 'hour');
+ }
+ }
+ } else {
+ const referenceYesterday = new Date(referenceNow);
+ referenceYesterday.setDate(referenceYesterday.getDate() - 1);
+ if (eventCalendarDay === referenceYesterday.toDateString()) {
+ const relativeDayPhrase = relativeTimeFormatter.format(-1, 'day');
+ const timeSegment = dateInstant.toLocaleTimeString(undefined, TIME_FORMAT_OPTIONS);
+ displayLabel = `${relativeDayPhrase}, ${timeSegment}`;
+ } else {
+ displayLabel = formatAbsolute(dateInstant, referenceNow);
+ }
+ }
+
+ return displayLabel;
+}
+
+export function formatColumnLastModified(lastModified) {
+ const lastModifiedDate = parseTimestamp(lastModified);
+ if (!lastModifiedDate) return { label: null };
+ return {
+ label: formatRelative(lastModifiedDate),
+ title: `Last modified on ${formatAbsolute(lastModifiedDate)}`,
+ };
+}
diff --git a/blocks/inventory/list/list.css b/blocks/inventory/list/list.css
new file mode 100644
index 00000000..81429299
--- /dev/null
+++ b/blocks/inventory/list/list.css
@@ -0,0 +1,276 @@
+:host {
+ display: block;
+ min-height: 0;
+ min-width: 0;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ -webkit-overflow-scrolling: touch;
+ background-color: var(--s2-gray-25);
+ font-family: var(--s2-font-family);
+ font-size: var(--s2-component-s-regular-font-size);
+ line-height: var(--s2-component-s-regular-line-height);
+ color: var(--s2-gray-900);
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+ pointer-events: none;
+}
+
+.sheet {
+ --rule: var(--s2-gray-200);
+
+ box-sizing: border-box;
+ width: 100%;
+ max-width: var(--browse-sheet-max-width, 1024px);
+ margin-inline: auto;
+ table-layout: fixed;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: var(--s2-component-m-regular-font-size);
+ line-height: var(--s2-component-m-regular-line-height);
+ font-weight: var(--s2-component-m-medium-font-weight);
+
+ & thead {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background-color: var(--s2-gray-25);
+ }
+
+ & :is(th, td) {
+ box-sizing: border-box;
+ vertical-align: middle;
+ }
+
+ & th {
+ text-align: left;
+ white-space: nowrap;
+ color: var(--s2-gray-700);
+ }
+
+ & thead th {
+ height: var(--s2-spacing-500);
+ font-weight: var(--s2-component-m-medium-font-weight);
+
+ /* Row-group borders are unreliable with `border-collapse: separate`. Inset
+ shadow draws the rule inside the cell so `height` is not grown by 1px like
+ `border-bottom` on table cells. */
+ background-color: var(--s2-gray-25);
+ box-shadow: inset 0 -1px 0 0 var(--rule);
+ }
+
+ & tbody td {
+ height: var(--s2-spacing-700);
+ padding: 0;
+ }
+
+ & tbody {
+ & td {
+ box-shadow: inset 0 -1px 0 0 var(--rule);
+ }
+
+ & td.column-modified {
+ white-space: nowrap;
+ }
+
+ & tr:last-child td {
+ border-bottom: none;
+ }
+
+ & tr.row {
+ &[aria-selected="true"] {
+ background-color: var(--s2-blue-100);
+ }
+
+ &:hover {
+ background-color: var(--s2-gray-75);
+ }
+
+ &[aria-selected="true"]:hover {
+ background-color: var(--s2-blue-100);
+ }
+ }
+
+ & tr.row-dir,
+ & tr.row-file {
+ cursor: pointer;
+ }
+ }
+
+ & :is(th, td).column-selection {
+ box-sizing: border-box;
+ width: 40px;
+ max-width: 44px;
+ text-align: center;
+ }
+
+ & :is(th, td).column-entry-type {
+ box-sizing: border-box;
+ width: 32px;
+ max-width: 32px;
+ padding-inline: var(--s2-spacing-75);
+ text-align: end;
+ }
+
+ & :is(th, td).column-file-name {
+ width: auto;
+ min-width: 0;
+ padding: 0 var(--s2-spacing-200) 0 var(--s2-spacing-75);
+ }
+
+ & :is(th, td).column-modified {
+ box-sizing: border-box;
+ width: 12.5rem;
+ max-width: 12.5rem;
+ padding: 0 var(--s2-spacing-200) 0 var(--s2-spacing-75);
+ }
+
+ .check {
+ margin: 0;
+ cursor: pointer;
+ vertical-align: middle;
+
+ & input {
+ appearance: none;
+ box-sizing: border-box;
+ width: var(--s2-body-size-s);
+ height: var(--s2-body-size-s);
+ margin: 0;
+ flex-shrink: 0;
+ border: 2px solid var(--s2-gray-900);
+ border-radius: var(--s2-corner-radius-75);
+ background-color: var(--s2-gray-25);
+ cursor: pointer;
+ }
+
+ & input:checked {
+ border-color: var(--s2-blue-900);
+ background-color: var(--s2-blue-900);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'%3E%3Cpath stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M2.5 6l2.5 3 4.5-5'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 9px 9px;
+ }
+
+ & input:indeterminate {
+ background-color: var(--s2-gray-25);
+ border-color: var(--s2-gray-900);
+ background-image: linear-gradient(var(--s2-gray-900), var(--s2-gray-900));
+ background-size: 7px 2px;
+ background-position: center;
+ background-repeat: no-repeat;
+ }
+
+ & input:focus-visible {
+ outline: 2px solid var(--s2-blue-900);
+ outline-offset: 2px;
+ }
+ }
+
+ /* Fill the select column so the whole hit area toggles the checkbox (label wraps input). */
+ & :is(th, td).column-selection .check {
+ display: flex;
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ min-height: var(--s2-spacing-700);
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ cursor: pointer;
+ vertical-align: middle;
+ }
+
+ & thead th.column-selection .check {
+ min-height: var(--s2-spacing-500);
+ }
+
+ .column-entry-type img {
+ display: block;
+ width: 18px;
+ height: 18px;
+ margin-inline-start: auto;
+ }
+
+ & .sort-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s2-spacing-75);
+ padding: 0;
+ border: none;
+ background: none;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+
+ &:hover {
+ color: var(--s2-gray-900);
+ }
+ }
+
+ & .sort-indicator {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ opacity: 0;
+ transition: transform 0.15s ease, opacity 0.15s ease;
+ flex-shrink: 0;
+
+ & svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ & :is(path, circle, rect, ellipse, line, polyline, polygon) {
+ fill: currentcolor;
+ }
+ }
+
+ & th[aria-sort="ascending"] .sort-indicator,
+ & th[aria-sort="descending"] .sort-indicator {
+ opacity: 1;
+ }
+
+ & .sort-indicator-desc {
+ transform: rotate(180deg);
+ }
+
+ & .filename {
+ display: block;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--s2-gray-900);
+ font-weight: var(--s2-component-m-bold-font-weight);
+ }
+
+ & .rename-input {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ font: inherit;
+ font-weight: var(--s2-component-m-bold-font-weight);
+ color: var(--s2-gray-900);
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid var(--s2-blue-900);
+ border-radius: 0;
+ outline: none;
+ padding: 0;
+ white-space: nowrap;
+ }
+}
diff --git a/blocks/inventory/list/list.js b/blocks/inventory/list/list.js
new file mode 100644
index 00000000..51b530df
--- /dev/null
+++ b/blocks/inventory/list/list.js
@@ -0,0 +1,288 @@
+import { LitElement, html, nothing } from 'da-lit';
+import { getNx } from '../../../scripts/utils.js';
+import { formatColumnLastModified } from './format.js';
+import {
+ getIconByExtension,
+ isFolder,
+ itemRowPathKey,
+ ICON_URLS,
+} from '../utils.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+const { loadHrefSvg, ICONS_BASE } = await import(`${getNx()}/utils/svg.js`);
+
+const styles = await loadStyle(import.meta.url);
+const sortArrowSvg = await loadHrefSvg(`${ICONS_BASE}S2_Icon_ArrowUpSend_20_N.svg`);
+
+/** `''` stays empty (e.g. folders); `null` / `undefined` → em dash for missing data. */
+function browseCellText(label) {
+ if (label === '') return '';
+ return label ?? '—';
+}
+
+export class NxBrowseList extends LitElement {
+ static properties = {
+ items: { type: Array },
+ currentPathKey: { type: String, attribute: 'current-path-key' },
+ renamingKey: { attribute: false },
+ _selectedKeys: { state: true },
+ _sort: { state: true },
+ };
+
+ willUpdate(changedProperties) {
+ if (changedProperties.has('currentPathKey')) {
+ this._selectedKeys = [];
+ this._emitSelectionChange();
+ }
+ }
+
+ updated(changed) {
+ if (changed.has('renamingKey') && this.renamingKey) {
+ const input = this.shadowRoot?.querySelector('.rename-input');
+ if (input) {
+ input.focus();
+ input.select();
+ }
+ }
+ const input = this.shadowRoot?.getElementById('select-all');
+ if (!(input instanceof HTMLInputElement)) {
+ return;
+ }
+ if (this.items === undefined) {
+ return;
+ }
+ const { items } = this;
+ const selectedKeys = this._selectedKeys ?? [];
+ const keys = items.map((item) => itemRowPathKey(this.currentPathKey, item));
+ const selectedCount = keys.filter((rowKey) => selectedKeys.includes(rowKey)).length;
+ input.indeterminate = selectedCount > 0 && selectedCount < keys.length;
+ if (keys.length === 0) {
+ input.checked = false;
+ input.indeterminate = false;
+ }
+ }
+
+ get _sortedItems() {
+ if (!this.items || !this._sort) return this.items;
+ const { col, dir } = this._sort;
+ return [...this.items].sort((a, b) => {
+ const av = a[col] ?? '';
+ const bv = b[col] ?? '';
+ if (av > bv) return dir === 'asc' ? 1 : -1;
+ if (av < bv) return dir === 'asc' ? -1 : 1;
+ return 0;
+ });
+ }
+
+ _onSortColumn(col) {
+ const dir = this._sort?.col === col && this._sort.dir === 'asc' ? 'desc' : 'asc';
+ this._sort = { col, dir };
+ }
+
+ _ariaSort(col) {
+ if (this._sort?.col !== col) return nothing;
+ return this._sort.dir === 'asc' ? 'ascending' : 'descending';
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [styles];
+ }
+
+ _renderSortIcon(col) {
+ const dirClass = this._sort?.col === col ? this._sort.dir : 'none';
+ return html`${sortArrowSvg?.cloneNode(true) ?? nothing}`;
+ }
+
+ _renderIcon(iconKey) {
+ const src = ICON_URLS[iconKey];
+ return src ? html`
` : nothing;
+ }
+
+ _onRowActivate(event, item) {
+ event.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('nx-browse-activate', {
+ detail: {
+ pathKey: itemRowPathKey(this.currentPathKey, item),
+ item,
+ shiftKey: event.shiftKey,
+ ctrlKey: event.ctrlKey || event.metaKey,
+ },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ _emitSelectionChange() {
+ const selectedKeys = [...(this._selectedKeys ?? [])];
+ const selected = (this.items ?? [])
+ .map((item) => ({ key: itemRowPathKey(this.currentPathKey, item), item }))
+ .filter(({ key }) => selectedKeys.includes(key));
+ this.dispatchEvent(
+ new CustomEvent('nx-browse-selection-change', {
+ detail: { selected },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ clearSelection() {
+ this._selectedKeys = [];
+ this._emitSelectionChange();
+ }
+
+ _onRenameKeydown(e, item) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const newName = e.target.value.trim();
+ if (newName && newName !== item.name) {
+ this.dispatchEvent(new CustomEvent('nx-browse-rename', {
+ detail: { item, newName },
+ bubbles: true,
+ composed: true,
+ }));
+ } else {
+ this._onRenameBlur();
+ }
+ } else if (e.key === 'Escape') {
+ this._onRenameBlur();
+ }
+ }
+
+ _onRenameBlur() {
+ this.dispatchEvent(new CustomEvent('nx-browse-rename-cancel', { bubbles: true, composed: true }));
+ }
+
+ _isRowSelected(key) {
+ return (this._selectedKeys ?? []).includes(key);
+ }
+
+ _onSelectAllChange(event) {
+ event.stopPropagation();
+ const input = event.target;
+ if (!(input instanceof HTMLInputElement)) {
+ return;
+ }
+ if (this.items === undefined) {
+ return;
+ }
+ const { items } = this;
+ const keys = items.map((item) => itemRowPathKey(this.currentPathKey, item));
+ this._selectedKeys = input.checked ? [...keys] : [];
+ this._emitSelectionChange();
+ }
+
+ _onRowCheckboxChange(event, item) {
+ event.stopPropagation();
+ const input = event.target;
+ if (!(input instanceof HTMLInputElement)) {
+ return;
+ }
+ const key = itemRowPathKey(this.currentPathKey, item);
+ const selectedKeys = this._selectedKeys ?? [];
+ if (input.checked) {
+ this._selectedKeys = selectedKeys.includes(key)
+ ? selectedKeys
+ : [...selectedKeys, key];
+ } else {
+ this._selectedKeys = selectedKeys.filter((selectedKey) => selectedKey !== key);
+ }
+ this._emitSelectionChange();
+ }
+
+ render() {
+ if (this.items === undefined) {
+ return nothing;
+ }
+ const items = this._sortedItems;
+ const selectedKeys = this._selectedKeys ?? [];
+ const rowKeys = items.map((item) => itemRowPathKey(this.currentPathKey, item));
+ const selectedCount = rowKeys.filter((rowKey) => selectedKeys.includes(rowKey)).length;
+ const allSelected = items.length > 0 && selectedCount === items.length;
+
+ return html`
+
+ `;
+ }
+}
+
+if (!customElements.get('nx-browse-list')) {
+ customElements.define('nx-browse-list', NxBrowseList);
+}
diff --git a/blocks/inventory/overrides.css b/blocks/inventory/overrides.css
new file mode 100644
index 00000000..42d451b0
--- /dev/null
+++ b/blocks/inventory/overrides.css
@@ -0,0 +1,10 @@
+/* Prevent main from page-scrolling when browse is active */
+body:has(nx-browse) {
+ overflow: hidden;
+}
+
+/* Hide the toggle when the before panel is already visible */
+html:has(aside.panel[data-position="before"]:not([hidden]))
+ nx-browse::part(toggle-before) {
+ display: none;
+}
diff --git a/blocks/inventory/utils.js b/blocks/inventory/utils.js
new file mode 100644
index 00000000..87797f58
--- /dev/null
+++ b/blocks/inventory/utils.js
@@ -0,0 +1,84 @@
+export function parseRepoPath(fullpath) {
+ const trimmed = (fullpath || '').trim();
+ const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
+ const parts = normalized.slice(1).split('/').filter(Boolean);
+ if (parts.length < 2) return null;
+ const [org, site, ...rest] = parts;
+ const pathSegments = [org, site, ...rest];
+ return {
+ org,
+ site,
+ pathSegments,
+ fullpath: `/${pathSegments.join('/')}`,
+ contentPath: rest.join('/'),
+ };
+}
+
+export function contextToPathContext(context) {
+ if (!context) return null;
+ const { org, site, path } = context;
+ if (!org || !site) return null;
+ const base = `/${org}/${site}`;
+ const fullpath = path ? `${base}/${path.split('/').filter(Boolean).join('/')}` : base;
+ const parsed = parseRepoPath(fullpath);
+ return parsed ? { pathSegments: parsed.pathSegments, fullpath: parsed.fullpath } : null;
+}
+
+export function itemRowPathKey(folderPathKey, item) {
+ const name = item.name || '';
+ return folderPathKey ? `${folderPathKey}/${name}` : name;
+}
+
+/** Whether the list API row is a folder (no non-empty `ext`); files include `ext`. */
+export function isFolder(row) {
+ return row?.ext == null || String(row.ext).trim() === '';
+}
+
+/** Resource kind from extension (icons, list behavior). */
+export const RESOURCE_TYPE = Object.freeze({
+ folder: 'folder',
+ document: 'document',
+ media: 'media',
+ sheet: 'sheet',
+ file: 'file',
+});
+
+export const ICON_URLS = {
+ folder: '/img/icons/s2-icon-folder-20-n.svg',
+ fileText: '/img/icons/s2-icon-filetext-20-n.svg',
+ image: '/img/icons/s2-icon-image-20-n.svg',
+ table: '/img/icons/s2-icon-table-20-n.svg',
+};
+
+export function entryTypeFromExtension(ext) {
+ if (ext == null || ext === '') {
+ return RESOURCE_TYPE.folder;
+ }
+ const e = String(ext).replace(/^\./, '').toLowerCase();
+ if (['html', 'htm'].includes(e)) {
+ return RESOURCE_TYPE.document;
+ }
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov'].includes(e)) {
+ return RESOURCE_TYPE.media;
+ }
+ if (['json', 'xlsx', 'xls', 'csv'].includes(e)) {
+ return RESOURCE_TYPE.sheet;
+ }
+ return RESOURCE_TYPE.file;
+}
+
+export function getIconByExtension(ext) {
+ switch (entryTypeFromExtension(ext)) {
+ case RESOURCE_TYPE.folder:
+ return 'folder';
+ case RESOURCE_TYPE.document:
+ return 'fileText';
+ case RESOURCE_TYPE.media:
+ return 'image';
+ case RESOURCE_TYPE.sheet:
+ return 'table';
+ case RESOURCE_TYPE.file:
+ default:
+ return 'fileText';
+ }
+}
diff --git a/blocks/shared/daFiles.js b/blocks/shared/daFiles.js
new file mode 100644
index 00000000..783e84a1
--- /dev/null
+++ b/blocks/shared/daFiles.js
@@ -0,0 +1,27 @@
+import { getNx } from '../../scripts/utils.js';
+import { daFetch } from './utils.js';
+
+const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`);
+
+export async function listFolder(fullpath) {
+ let response;
+ try {
+ response = await daFetch(`${DA_ADMIN}/list${fullpath}`);
+ } catch (err) {
+ return { error: err instanceof Error ? err.message : 'List request failed', status: 0 };
+ }
+ if (!response.ok) return { error: `List failed: ${response.status}`, status: response.status };
+ try {
+ const payload = await response.json();
+ if (!Array.isArray(payload)) return { error: 'Invalid list response', status: response.status };
+ return payload;
+ } catch {
+ return { error: 'Invalid response body', status: response.status };
+ }
+}
+
+export function itemHashPath(item) {
+ if (!item?.path) return '';
+ if (!item.ext) return item.path.replace(/^\//, '');
+ return item.path.slice(1, -(item.ext.length + 1));
+}
diff --git a/img/icons/s2-icon-add-20-n.svg b/img/icons/s2-icon-add-20-n.svg
new file mode 100644
index 00000000..0bcb6cab
--- /dev/null
+++ b/img/icons/s2-icon-add-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-aichat-20-n.svg b/img/icons/s2-icon-aichat-20-n.svg
new file mode 100644
index 00000000..001abdae
--- /dev/null
+++ b/img/icons/s2-icon-aichat-20-n.svg
@@ -0,0 +1,5 @@
+
diff --git a/img/icons/s2-icon-arrowupsend-20-n.svg b/img/icons/s2-icon-arrowupsend-20-n.svg
new file mode 100644
index 00000000..35f2e0aa
--- /dev/null
+++ b/img/icons/s2-icon-arrowupsend-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-checkmark-20-n.svg b/img/icons/s2-icon-checkmark-20-n.svg
new file mode 100644
index 00000000..424d2dbb
--- /dev/null
+++ b/img/icons/s2-icon-checkmark-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-chevronup-20-n.svg b/img/icons/s2-icon-chevronup-20-n.svg
new file mode 100644
index 00000000..190284b9
--- /dev/null
+++ b/img/icons/s2-icon-chevronup-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-close-20-n.svg b/img/icons/s2-icon-close-20-n.svg
new file mode 100644
index 00000000..eec3dc69
--- /dev/null
+++ b/img/icons/s2-icon-close-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-delete-20-n.svg b/img/icons/s2-icon-delete-20-n.svg
new file mode 100644
index 00000000..a8714278
--- /dev/null
+++ b/img/icons/s2-icon-delete-20-n.svg
@@ -0,0 +1,7 @@
+
diff --git a/img/icons/s2-icon-openin-20-n.svg b/img/icons/s2-icon-openin-20-n.svg
new file mode 100644
index 00000000..766a32e4
--- /dev/null
+++ b/img/icons/s2-icon-openin-20-n.svg
@@ -0,0 +1,6 @@
+
diff --git a/img/icons/s2-icon-paste-20-n.svg b/img/icons/s2-icon-paste-20-n.svg
new file mode 100644
index 00000000..4f14e7b2
--- /dev/null
+++ b/img/icons/s2-icon-paste-20-n.svg
@@ -0,0 +1,4 @@
+
diff --git a/img/icons/s2-icon-preview-20-n.svg b/img/icons/s2-icon-preview-20-n.svg
new file mode 100644
index 00000000..8cbc266c
--- /dev/null
+++ b/img/icons/s2-icon-preview-20-n.svg
@@ -0,0 +1,5 @@
+
diff --git a/img/icons/s2-icon-publish-20-n.svg b/img/icons/s2-icon-publish-20-n.svg
new file mode 100644
index 00000000..a03a4621
--- /dev/null
+++ b/img/icons/s2-icon-publish-20-n.svg
@@ -0,0 +1,5 @@
+
diff --git a/img/icons/s2-icon-removecircle-20-n.svg b/img/icons/s2-icon-removecircle-20-n.svg
new file mode 100644
index 00000000..f16b9653
--- /dev/null
+++ b/img/icons/s2-icon-removecircle-20-n.svg
@@ -0,0 +1,4 @@
+
diff --git a/img/icons/s2-icon-search-20-n.svg b/img/icons/s2-icon-search-20-n.svg
new file mode 100644
index 00000000..ced34c88
--- /dev/null
+++ b/img/icons/s2-icon-search-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-share-20-n.svg b/img/icons/s2-icon-share-20-n.svg
new file mode 100644
index 00000000..b3bf9c9a
--- /dev/null
+++ b/img/icons/s2-icon-share-20-n.svg
@@ -0,0 +1,9 @@
+
diff --git a/img/icons/s2-icon-splitleft-20-n.svg b/img/icons/s2-icon-splitleft-20-n.svg
new file mode 100644
index 00000000..1fd6ef8d
--- /dev/null
+++ b/img/icons/s2-icon-splitleft-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-splitright-20-n.svg b/img/icons/s2-icon-splitright-20-n.svg
new file mode 100644
index 00000000..4fd5b12b
--- /dev/null
+++ b/img/icons/s2-icon-splitright-20-n.svg
@@ -0,0 +1,3 @@
+
diff --git a/img/icons/s2-icon-stop-20-n.svg b/img/icons/s2-icon-stop-20-n.svg
new file mode 100644
index 00000000..4ce6b01d
--- /dev/null
+++ b/img/icons/s2-icon-stop-20-n.svg
@@ -0,0 +1,3 @@
+