From 9cf9ae84e5508c0ec7885d95aa4200220258a5c6 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Wed, 6 May 2026 16:10:04 +0200 Subject: [PATCH 01/19] move ew files --- blocks/canvas-actions/canvas-actions.css | 124 +++++ blocks/canvas-actions/canvas-actions.js | 120 +++++ blocks/canvas/canvas.css | 57 ++ blocks/canvas/canvas.js | 208 ++++++++ blocks/canvas/editor-utils/command-defs.js | 265 ++++++++++ blocks/canvas/editor-utils/command-helpers.js | 214 ++++++++ blocks/canvas/editor-utils/document.js | 155 ++++++ .../canvas/editor-utils/extensions-bridge.js | 20 + .../editor-utils/nx-selection-toolbar.css | 169 ++++++ .../editor-utils/nx-selection-toolbar.js | 306 +++++++++++ blocks/canvas/editor-utils/preview.js | 34 ++ blocks/canvas/editor-utils/prose-diff.js | 172 ++++++ .../canvas/editor-utils/selection-toolbar.js | 94 ++++ blocks/canvas/editor-utils/state.js | 97 ++++ blocks/canvas/img/s2-icon-blockcode-20-n.svg | 1 + blocks/canvas/img/s2-icon-blockquote-20-n.svg | 1 + .../canvas/img/s2-icon-gridcompare-20-n.svg | 6 + blocks/canvas/img/s2-icon-heading1-20-n.svg | 5 + blocks/canvas/img/s2-icon-heading2-20-n.svg | 5 + blocks/canvas/img/s2-icon-heading3-20-n.svg | 5 + blocks/canvas/img/s2-icon-heading4-20-n.svg | 5 + blocks/canvas/img/s2-icon-heading5-20-n.svg | 5 + blocks/canvas/img/s2-icon-heading6-20-n.svg | 5 + blocks/canvas/img/s2-icon-rail-20-n.svg | 14 + blocks/canvas/img/s2-icon-separator-20-n.svg | 7 + blocks/canvas/img/s2-icon-splitleft-20-n.svg | 4 + blocks/canvas/img/s2-icon-splitright-20-n.svg | 4 + blocks/canvas/img/s2-icon-tableadd-20-n.svg | 6 + .../img/s2-icon-textindentdecrease-20-n.svg | 8 + .../img/s2-icon-textindentincrease-20-n.svg | 8 + .../nx-canvas-header/nx-canvas-header.css | 164 ++++++ .../nx-canvas-header/nx-canvas-header.js | 133 +++++ blocks/canvas/nx-editor-doc/nx-editor-doc.css | 488 ++++++++++++++++++ blocks/canvas/nx-editor-doc/nx-editor-doc.js | 313 +++++++++++ .../prose-plugins/base64Uploader.js | 82 +++ .../nx-editor-doc/prose-plugins/codemark.js | 50 ++ .../prose-plugins/focalPointDialog.js | 243 +++++++++ .../nx-editor-doc/prose-plugins/imageDrop.js | 57 ++ .../prose-plugins/imageFocalPoint.js | 154 ++++++ .../nx-editor-doc/prose-plugins/inlinesvg.js | 21 + .../prose-plugins/sectionPasteHandler.js | 158 ++++++ .../prose-plugins/sourceUploadContext.js | 29 ++ .../prose-plugins/tableSelectHandle.js | 135 +++++ .../nx-editor-doc/prose-plugins/tableUtils.js | 47 ++ blocks/canvas/nx-editor-doc/prose.js | 171 ++++++ .../nx-editor-doc/slash-menu/slash-menu.js | 179 +++++++ .../nx-editor-doc/utils/awareness-users.js | 29 ++ blocks/canvas/nx-editor-doc/utils/collab.js | 40 ++ blocks/canvas/nx-editor-doc/utils/ctx.js | 29 ++ .../nx-editor-doc/utils/load-editor-doc.js | 20 + .../nx-editor-doc/utils/quick-edit-host.js | 28 + .../nx-editor-doc/utils/shadow-mount.js | 19 + blocks/canvas/nx-editor-doc/utils/source.js | 21 + blocks/canvas/nx-editor-doc/utils/teardown.js | 23 + .../nx-editor-split/nx-editor-split.css | 43 ++ .../canvas/nx-editor-split/nx-editor-split.js | 119 +++++ .../nx-editor-wysiwyg/nx-editor-wysiwyg.css | 37 ++ .../nx-editor-wysiwyg/nx-editor-wysiwyg.js | 233 +++++++++ .../quick-edit-controller.js | 30 ++ .../canvas/nx-editor-wysiwyg/utils/blocks.js | 37 ++ .../nx-editor-wysiwyg/utils/handlers.js | 161 ++++++ .../canvas/nx-editor-wysiwyg/utils/image.js | 122 +++++ .../nx-page-outline/nx-page-outline.css | 118 +++++ .../canvas/nx-page-outline/nx-page-outline.js | 152 ++++++ .../canvas/nx-panel-extensions/aem-assets.js | 171 ++++++ blocks/canvas/nx-panel-extensions/helpers.js | 463 +++++++++++++++++ .../nx-panel-extensions/iframe-protocol.js | 90 ++++ .../nx-panel-extensions.css | 14 + .../nx-panel-extensions.js | 57 ++ .../nx-panel-extensions/nx-panel-library.css | 296 +++++++++++ .../nx-panel-extensions/nx-panel-library.js | 267 ++++++++++ .../nx-panel-header/nx-panel-header.css | 53 ++ .../canvas/nx-panel-header/nx-panel-header.js | 31 ++ blocks/inventory/browse-api.js | 29 ++ blocks/inventory/inventory.css | 129 +++++ blocks/inventory/inventory.js | 210 ++++++++ blocks/inventory/list/format.js | 66 +++ blocks/inventory/list/list.css | 224 ++++++++ blocks/inventory/list/list.js | 198 +++++++ blocks/inventory/overrides.css | 24 + blocks/inventory/utils.js | 86 +++ blocks/shared/nxutils.js | 27 + 82 files changed, 8244 insertions(+) create mode 100644 blocks/canvas-actions/canvas-actions.css create mode 100644 blocks/canvas-actions/canvas-actions.js create mode 100644 blocks/canvas/canvas.css create mode 100644 blocks/canvas/canvas.js create mode 100644 blocks/canvas/editor-utils/command-defs.js create mode 100644 blocks/canvas/editor-utils/command-helpers.js create mode 100644 blocks/canvas/editor-utils/document.js create mode 100644 blocks/canvas/editor-utils/extensions-bridge.js create mode 100644 blocks/canvas/editor-utils/nx-selection-toolbar.css create mode 100644 blocks/canvas/editor-utils/nx-selection-toolbar.js create mode 100644 blocks/canvas/editor-utils/preview.js create mode 100644 blocks/canvas/editor-utils/prose-diff.js create mode 100644 blocks/canvas/editor-utils/selection-toolbar.js create mode 100644 blocks/canvas/editor-utils/state.js create mode 100644 blocks/canvas/img/s2-icon-blockcode-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-blockquote-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-gridcompare-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-heading1-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-heading2-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-heading3-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-heading4-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-heading5-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-heading6-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-rail-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-separator-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-splitleft-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-splitright-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-tableadd-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-textindentdecrease-20-n.svg create mode 100644 blocks/canvas/img/s2-icon-textindentincrease-20-n.svg create mode 100644 blocks/canvas/nx-canvas-header/nx-canvas-header.css create mode 100644 blocks/canvas/nx-canvas-header/nx-canvas-header.js create mode 100644 blocks/canvas/nx-editor-doc/nx-editor-doc.css create mode 100644 blocks/canvas/nx-editor-doc/nx-editor-doc.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/codemark.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/focalPointDialog.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/imageDrop.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/imageFocalPoint.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/inlinesvg.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/sectionPasteHandler.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/tableSelectHandle.js create mode 100644 blocks/canvas/nx-editor-doc/prose-plugins/tableUtils.js create mode 100644 blocks/canvas/nx-editor-doc/prose.js create mode 100644 blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js create mode 100644 blocks/canvas/nx-editor-doc/utils/awareness-users.js create mode 100644 blocks/canvas/nx-editor-doc/utils/collab.js create mode 100644 blocks/canvas/nx-editor-doc/utils/ctx.js create mode 100644 blocks/canvas/nx-editor-doc/utils/load-editor-doc.js create mode 100644 blocks/canvas/nx-editor-doc/utils/quick-edit-host.js create mode 100644 blocks/canvas/nx-editor-doc/utils/shadow-mount.js create mode 100644 blocks/canvas/nx-editor-doc/utils/source.js create mode 100644 blocks/canvas/nx-editor-doc/utils/teardown.js create mode 100644 blocks/canvas/nx-editor-split/nx-editor-split.css create mode 100644 blocks/canvas/nx-editor-split/nx-editor-split.js create mode 100644 blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.css create mode 100644 blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js create mode 100644 blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js create mode 100644 blocks/canvas/nx-editor-wysiwyg/utils/blocks.js create mode 100644 blocks/canvas/nx-editor-wysiwyg/utils/handlers.js create mode 100644 blocks/canvas/nx-editor-wysiwyg/utils/image.js create mode 100644 blocks/canvas/nx-page-outline/nx-page-outline.css create mode 100644 blocks/canvas/nx-page-outline/nx-page-outline.js create mode 100644 blocks/canvas/nx-panel-extensions/aem-assets.js create mode 100644 blocks/canvas/nx-panel-extensions/helpers.js create mode 100644 blocks/canvas/nx-panel-extensions/iframe-protocol.js create mode 100644 blocks/canvas/nx-panel-extensions/nx-panel-extensions.css create mode 100644 blocks/canvas/nx-panel-extensions/nx-panel-extensions.js create mode 100644 blocks/canvas/nx-panel-extensions/nx-panel-library.css create mode 100644 blocks/canvas/nx-panel-extensions/nx-panel-library.js create mode 100644 blocks/canvas/nx-panel-header/nx-panel-header.css create mode 100644 blocks/canvas/nx-panel-header/nx-panel-header.js create mode 100644 blocks/inventory/browse-api.js create mode 100644 blocks/inventory/inventory.css create mode 100644 blocks/inventory/inventory.js create mode 100644 blocks/inventory/list/format.js create mode 100644 blocks/inventory/list/list.css create mode 100644 blocks/inventory/list/list.js create mode 100644 blocks/inventory/overrides.css create mode 100644 blocks/inventory/utils.js create mode 100644 blocks/shared/nxutils.js diff --git a/blocks/canvas-actions/canvas-actions.css b/blocks/canvas-actions/canvas-actions.css new file mode 100644 index 000000000..4f888651c --- /dev/null +++ b/blocks/canvas-actions/canvas-actions.css @@ -0,0 +1,124 @@ +:host { + display: block; + box-sizing: border-box; + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-s); + color: var(--s2-gray-800); +} + +.canvas-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--s2-spacing-100); +} + +.canvas-actions .right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--s2-spacing-75); +} + +.preview-row { + display: inline-flex; + align-items: center; + gap: var(--s2-spacing-100); + flex-wrap: wrap; + justify-content: flex-end; +} + +.preview-dropdown-btn { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 32px; + height: 32px; + padding: 0; + margin: 0; + border: none; + border-radius: var(--s2-corner-radius-800); + color: light-dark(var(--s2-gray-25), #fff); + background-color: var(--s2-blue-900); + cursor: pointer; +} + +.preview-dropdown-btn:focus-visible { + outline: 2px solid var(--s2-blue-800); + outline-offset: 2px; +} + +.preview-dropdown-btn:disabled { + color: var(--s2-gray-400); + background-color: var(--s2-gray-200); + cursor: not-allowed; +} + +.preview-dropdown-btn:hover:not(:disabled) { + background-color: var(--s2-blue-1000); +} + +.preview-dropdown-btn:active:not(:disabled) { + background-color: var(--s2-blue-1100); +} + +.preview-dropdown-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; +} + +.preview-dropdown-icon img { + display: block; + width: 18px; + height: 18px; +} + +.preview-dropdown-label { + white-space: nowrap; +} + +.send-popover { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 148px; +} + +.send-popover-item { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + padding: var(--s2-spacing-75) var(--s2-spacing-100); + border: none; + border-radius: var(--s2-corner-radius-200); + background: none; + color: light-dark(var(--s2-gray-900), var(--s2-gray-800)); + font-family: inherit; + font-size: var(--s2-body-size-s); + font-weight: 500; + line-height: var(--s2-body-line-height, 1.5); + text-align: left; + cursor: pointer; +} + +.send-popover-item:hover { + background: light-dark(var(--s2-gray-200), var(--s2-gray-300)); +} + +.send-popover-item:focus-visible { + outline: 2px solid var(--s2-blue-800); + outline-offset: 1px; +} + +.action-error { + margin: 0; + max-width: 280px; + font-size: var(--s2-body-size-xs, 0.75rem); + font-weight: 500; + color: var(--s2-negative-900, #c00); + text-align: right; +} diff --git a/blocks/canvas-actions/canvas-actions.js b/blocks/canvas-actions/canvas-actions.js new file mode 100644 index 000000000..781c85740 --- /dev/null +++ b/blocks/canvas-actions/canvas-actions.js @@ -0,0 +1,120 @@ +import { LitElement, html, nothing } from 'da-lit'; + +import { + getNx, loadStyle, HashController, + buildAemPathFromHashState, formatAemPreviewPublishError, runAemPreviewOrPublish, +} from '../shared/nxutils.js'; + +await import(`${getNx()}/blocks/shared/popover/popover.js`); + +const style = await loadStyle(import.meta.url); + +class NXCanvasActions extends LitElement { + static properties = { + _busy: { state: true }, + _error: { state: true }, + }; + + _hash = new HashController(this); + + _busy = false; + + get _popover() { + return this.shadowRoot?.querySelector('nx-popover'); + } + + get _menuAnchor() { + return this.shadowRoot?.querySelector('.preview-dropdown-btn'); + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + } + + get _hashState() { + return this._hash.value; + } + + _togglePreviewPopover(e) { + e.preventDefault(); + if (!buildAemPathFromHashState(this._hashState) || this._busy) return; + const pop = this._popover; + const anchor = this._menuAnchor; + if (!pop || !anchor) return; + if (pop.open) { + pop.close(); + } else { + pop.show({ anchor, placement: 'below' }); + anchor.setAttribute('aria-expanded', 'true'); + } + } + + _onSendPopoverClose() { + this._menuAnchor?.setAttribute('aria-expanded', 'false'); + } + + _pickAem(action) { + if (action !== 'preview' && action !== 'publish') return; + this._popover?.close(); + this._runAemAction(action); + } + + async _runAemAction(action) { + const aemPath = buildAemPathFromHashState(this._hashState); + if (!aemPath || this._busy) return; + + this._error = undefined; + this._busy = true; + + const result = await runAemPreviewOrPublish({ aemPath, action }); + if (!result.ok) { + this._error = formatAemPreviewPublishError(result.error); + this._busy = false; + return; + } + + window.open(result.url, result.url); + + this._busy = false; + } + + render() { + const hasDoc = Boolean(buildAemPathFromHashState(this._hashState)); + const disabled = !hasDoc || this._busy; + const sendIcon = html``; + + return html` +
+
+
+ + + + +
+ ${this._error ? html`` : nothing} +
+
+ `; + } +} + +customElements.define('nx-canvas-actions', NXCanvasActions); diff --git a/blocks/canvas/canvas.css b/blocks/canvas/canvas.css new file mode 100644 index 000000000..d87ae17bb --- /dev/null +++ b/blocks/canvas/canvas.css @@ -0,0 +1,57 @@ +:root { + --nx-canvas-header-height: 48px; +} + +html:has(aside.panel[data-position="before"]:not([hidden])) nx-canvas-header::part(toggle-before) { + display: none; +} + +html:has(aside.panel[data-position="after"]:not([hidden])) nx-canvas-header::part(toggle-after) { + display: none; +} + +.fragment-content:has(nx-chat) { + height: 100%; + + & .section { + height: 100%; + } + + & .block-content { + height: 100%; + } +} + +nx-chat { + height: 100%; +} + +/* Layout / content / split: one visible editor set; split uses WYSIWYG | gutter | doc; inactive uses [hidden] */ +.nx-canvas-editor-mount { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 0; + height: calc(100vh - var(--nx-canvas-header-height) - var(--s2-nav-height)); +} + +nx-editor-doc { + display: block; + flex: 1; + min-height: 0; + max-height: calc(100vh - var(--nx-canvas-header-height) - var(--s2-nav-height)); + overflow-y: auto; +} + +nx-editor-wysiwyg, +nx-editor-doc { + contain: layout; + overflow: hidden; +} + +nx-editor-doc[hidden], +nx-editor-wysiwyg[hidden] { + display: none !important; +} + diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js new file mode 100644 index 000000000..387514def --- /dev/null +++ b/blocks/canvas/canvas.js @@ -0,0 +1,208 @@ +import { getNx, loadStyle, hashChange, getPanelStore, openPanel } from '../shared/nxutils.js'; +import './nx-canvas-header/nx-canvas-header.js'; +import './nx-editor-doc/nx-editor-doc.js'; +import './nx-editor-wysiwyg/nx-editor-wysiwyg.js'; +import { + syncEditorSplitLayout, + finalizeSplitEditorMountOrder, + installEditorSplitDrag, + removeSplitGutter, +} from './nx-editor-split/nx-editor-split.js'; + +const style = await loadStyle(import.meta.url); +document.adoptedStyleSheets = [...document.adoptedStyleSheets, style]; + +function buildCanvasDocPath(state) { + const { org, site, path } = state || {}; + if (!org || !site || !path) return null; + return `${org}/${site}/${path}`; +} + +const CANVAS_EDITOR_VIEW_KEY = 'nx-canvas-editor-view'; + +function normalizeCanvasEditorView(view) { + if (view === 'content') return 'content'; + if (view === 'split') return 'split'; + return 'layout'; +} + +function notifyCanvasEditorActive(mountRoot, view) { + const v = normalizeCanvasEditorView(view); + mountRoot.dispatchEvent(new CustomEvent('nx-canvas-editor-active', { + bubbles: false, + detail: { view: v }, + })); +} + +function readPersistedCanvasEditorView() { + try { + return normalizeCanvasEditorView(sessionStorage.getItem(CANVAS_EDITOR_VIEW_KEY)); + } catch { + return 'layout'; + } +} + +function persistCanvasEditorView(view) { + try { + sessionStorage.setItem(CANVAS_EDITOR_VIEW_KEY, normalizeCanvasEditorView(view)); + } catch { + /* ignore if browser disallows session storage */ + } +} + +function canvasEditorMountRoot(block) { + return block.querySelector('.default-content') || block; +} + +function canvasHeaderApplyTarget(block) { + return block.querySelector('.nx-canvas-editor-mount') + || block.querySelector('.default-content') + || block; +} + +function removeCanvasEditors(mountRoot) { + removeSplitGutter(mountRoot); + mountRoot.querySelector('nx-editor-doc')?.remove(); + mountRoot.querySelector('nx-editor-wysiwyg')?.remove(); +} + +function ensureNxEditorDoc(mountRoot) { + let el = mountRoot.querySelector('nx-editor-doc'); + if (!el) { + el = document.createElement('nx-editor-doc'); + mountRoot.append(el); + } + return el; +} + +function ensureNxEditorWysiwyg(mountRoot) { + let frame = mountRoot.querySelector('nx-editor-wysiwyg'); + if (!frame) { + frame = document.createElement('nx-editor-wysiwyg'); + mountRoot.append(frame); + } + return frame; +} + +function editorCtxFromHashState(state, fullPath) { + return { org: state.org, repo: state.site, path: fullPath }; +} + +function syncCanvasEditorsToHash({ mountRoot, header, state }) { + header.undoAvailable = false; + header.redoAvailable = false; + const fullPath = buildCanvasDocPath(state); + if (!fullPath) { + removeCanvasEditors(mountRoot); + return; + } + const ctx = editorCtxFromHashState(state, fullPath); + ensureNxEditorWysiwyg(mountRoot).ctx = ctx; + ensureNxEditorDoc(mountRoot).ctx = ctx; + finalizeSplitEditorMountOrder(mountRoot); + notifyCanvasEditorActive(mountRoot, header.editorView); + syncEditorSplitLayout({ mountRoot, view: header.editorView }); +} + +async function syncToolPanelViews(toolPanel, { org, site }) { + const key = org && site ? `${org}/${site}` : null; + if (key === toolPanel.dataset.extKey) return; + toolPanel.dataset.extKey = key ?? ''; + + if (!key) { + toolPanel.views = []; + return; + } + + const { getCanvasToolPanelViews } = await import('./nx-panel-extensions/helpers.js'); + const views = await getCanvasToolPanelViews({ org, site }); + if (toolPanel.dataset.extKey !== key) return; + toolPanel.views = views; +} + +const CANVAS_PANELS = { + before: { + width: '400px', + getContent: async () => { + await import(`${getNx()}/blocks/chat/chat.js`); + return document.createElement('nx-chat'); + }, + }, + after: { + width: '400px', + getContent: async () => { + await import(`${getNx()}/blocks/tool-panel/tool-panel.js`); + return document.createElement('nx-tool-panel'); + }, + }, +}; + +function hashState() { + const [org, site] = window.location.hash.slice(2).split('/'); + return { org: org || undefined, site: site || undefined }; +} + +async function openCanvasPanel(position, { preferredViewId } = {}) { + const config = CANVAS_PANELS[position]; + if (!config) return; + const store = getPanelStore(); + const width = store[position]?.width ?? config.width; + const aside = await openPanel({ position, width, getContent: config.getContent }); + if (position === 'after') { + const toolPanel = aside?.querySelector('nx-tool-panel'); + if (toolPanel) { + await syncToolPanelViews(toolPanel, hashState()); + await toolPanel.updateComplete; + if (preferredViewId && toolPanel.views?.some((v) => v.id === preferredViewId)) { + await toolPanel.showView(preferredViewId); + } + } + } +} + +function installCanvasHeader(block) { + const header = document.createElement('nx-canvas-header'); + header.editorView = readPersistedCanvasEditorView(); + header.addEventListener('nx-canvas-open-panel', (e) => { + openCanvasPanel(e.detail.position, { preferredViewId: e.detail.viewId }); + }); + header.addEventListener('nx-canvas-editor-view', (e) => { + const view = normalizeCanvasEditorView(e.detail?.view); + persistCanvasEditorView(view); + const applyTarget = canvasHeaderApplyTarget(block); + notifyCanvasEditorActive(applyTarget, view); + syncEditorSplitLayout({ mountRoot: canvasEditorMountRoot(block), view }); + }); + header.addEventListener('nx-canvas-undo', () => { + canvasEditorMountRoot(block).querySelector('nx-editor-doc')?.undo(); + }); + header.addEventListener('nx-canvas-redo', () => { + canvasEditorMountRoot(block).querySelector('nx-editor-doc')?.redo(); + }); + block.before(header); + return header; +} + +export default async function decorate(block) { + const header = installCanvasHeader(block); + + const mountRoot = canvasEditorMountRoot(block); + mountRoot.classList.add('nx-canvas-editor-mount'); + syncEditorSplitLayout({ mountRoot, view: header.editorView }); + installEditorSplitDrag(mountRoot); + + mountRoot.addEventListener('nx-editor-undo-state', (e) => { + header.undoAvailable = e.detail?.canUndo ?? false; + header.redoAvailable = e.detail?.canRedo ?? false; + }); + + hashChange.subscribe((state) => { + syncCanvasEditorsToHash({ mountRoot, header, state }); + const toolPanel = document.querySelector('aside.panel[data-position="after"] nx-tool-panel'); + if (toolPanel) syncToolPanelViews(toolPanel, state); + }); + + const store = getPanelStore(); + if (store.before && !store.before.fragment) openCanvasPanel('before'); + if (store.after && !store.after.fragment) openCanvasPanel('after'); +} diff --git a/blocks/canvas/editor-utils/command-defs.js b/blocks/canvas/editor-utils/command-defs.js new file mode 100644 index 000000000..39af5dca5 --- /dev/null +++ b/blocks/canvas/editor-utils/command-defs.js @@ -0,0 +1,265 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { DOMParser, Fragment } from 'da-y-wrapper'; +import { + blockType, + wrap, + list, + inlineMark, + sinkListLevel, + liftListLevel, + markIsActive, + inList, + canSinkList, + canLiftList, + getTableHeading, + getTableBody, + LOREM_SENTENCES, +} from './command-helpers.js'; + +export const COMMANDS = [ + // Toolbar: inline mark buttons + { + id: 'strong', + label: 'Bold', + schema: 'strong', + icon: 'TagBold', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'strong'), + apply: inlineMark('strong'), + }, + { + id: 'em', + label: 'Italic', + schema: 'em', + icon: 'TagItalic', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'em'), + apply: inlineMark('em'), + }, + { + id: 'code', + label: 'Inline code', + schema: 'code', + icon: 'Code', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'code'), + apply: inlineMark('code'), + }, + { + id: 'underline', + label: 'Underline', + schema: 'u', + icon: 'TagUnderline', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 'u'), + apply: inlineMark('u'), + }, + { + id: 'strikethrough', + label: 'Strikethrough', + schema: 's', + icon: 'TagStrikeThrough', + showIn: ['toolbar-marks'], + active: (state) => markIsActive(state, 's'), + apply: inlineMark('s'), + }, + + // Toolbar: block-type picker + { + id: 'paragraph', + label: 'Paragraph', + schema: 'paragraph', + showIn: ['toolbar-picker'], + apply: blockType('paragraph'), + }, + { + id: 'heading-1', + label: 'Heading 1', + icon: 'Heading1', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 1 }), + }, + { + id: 'heading-2', + label: 'Heading 2', + icon: 'Heading2', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 2 }), + }, + { + id: 'heading-3', + label: 'Heading 3', + icon: 'Heading3', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 3 }), + }, + { + id: 'heading-4', + label: 'Heading 4', + icon: 'Heading4', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 4 }), + }, + { + id: 'heading-5', + label: 'Heading 5', + icon: 'Heading5', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 5 }), + }, + { + id: 'heading-6', + label: 'Heading 6', + icon: 'Heading6', + schema: 'heading', + showIn: ['toolbar-picker', 'slash-text'], + apply: blockType('heading', { level: 6 }), + }, + { + id: 'code-block', + label: 'Code block', + icon: 'BlockCode', + schema: 'code_block', + showIn: ['toolbar-picker', 'slash-text'], + disabled: (state) => state.selection.$from.parent.type.name === 'code_block', + apply: blockType('code_block'), + }, + + // Toolbar: structure buttons + { + id: 'blockquote', + label: 'Blockquote', + icon: 'BlockQuote', + schema: 'blockquote', + showIn: ['toolbar-structure', 'slash-text'], + apply: wrap('blockquote'), + }, + { + id: 'bullet-list', + label: 'Bullet list', + icon: 'ListBulleted', + schema: 'bullet_list', + showIn: ['toolbar-structure', 'slash-text'], + visible: ({ selection: { $from } }) => !inList($from), + apply: list('bullet_list'), + }, + { + id: 'numbered-list', + label: 'Numbered list', + icon: 'ListNumbered', + schema: 'ordered_list', + showIn: ['toolbar-structure', 'slash-text'], + visible: ({ selection: { $from } }) => !inList($from), + apply: list('ordered_list'), + }, + { + id: 'list-indent', + label: 'Indent list', + icon: 'TextIndentIncrease', + showIn: ['toolbar-structure'], + visible: ({ selection: { $from } }) => inList($from), + disabled: (state) => !canSinkList(state), + apply: sinkListLevel, + }, + { + id: 'list-outdent', + label: 'Outdent list', + icon: 'TextIndentDecrease', + showIn: ['toolbar-structure'], + visible: ({ selection: { $from } }) => inList($from), + disabled: (state) => !canLiftList(state), + apply: liftListLevel, + }, + + // Slash menu: text section only + { + id: 'section-break', + label: 'Section break', + icon: 'Separator', + showIn: ['slash-text'], + apply: (view) => { + const div = document.createElement('div'); + div.append(document.createElement('hr'), document.createElement('p')); + const nodes = DOMParser.fromSchema(view.state.schema).parse(div); + view.dispatch(view.state.tr.replaceSelectionWith(nodes)); + }, + }, + { + id: 'lorem-ipsum', + label: 'Lorem ipsum', + icon: 'Rail', + showIn: ['slash-text'], + apply: (view) => { + const { $cursor } = view.state.selection; + if (!$cursor) return; + const text = Array.from( + { length: 5 }, + (_, i) => LOREM_SENTENCES[i % LOREM_SENTENCES.length], + ).join(' '); + view.dispatch( + view.state.tr.replaceWith($cursor.before(), $cursor.pos, view.state.schema.text(text)), + ); + }, + }, + + // Slash menu: blocks section + { + id: 'open-library', + label: 'Open library', + icon: 'CCLibrary', + showIn: ['slash-blocks'], + apply: () => { + const evt = new CustomEvent('nx-canvas-open-panel', { + bubbles: true, + composed: true, + detail: { position: 'after', viewId: 'blocks' }, + }); + document.querySelector('nx-canvas-header')?.dispatchEvent(evt); + }, + }, + { + id: 'insert-block', + label: 'Insert block', + icon: 'TableAdd', + showIn: ['slash-blocks'], + apply: (view) => { + const { state } = view; + const heading = getTableHeading(state.schema); + const body = getTableBody(state.schema); + const frag = document.createDocumentFragment(); + frag.append(document.createElement('p')); + const para = DOMParser.fromSchema(state.schema).parse(frag); + const node = state.schema.nodes.table.create(null, Fragment.fromArray([heading, body])); + const trx = state.tr.insert(state.selection.head, para); + trx.replaceSelectionWith(node).scrollIntoView(); + view.dispatch(trx); + }, + }, +]; + +export function commandsFor(showIn) { + return COMMANDS.filter((c) => c.showIn.includes(showIn)); +} + +export const COMMAND_BY_ID = new Map(COMMANDS.map((c) => [c.id, c])); + +const SLASH_GROUPS = [ + { section: 'Blocks', showIn: 'slash-blocks' }, + { section: 'Text', showIn: 'slash-text' }, +]; + +export function slashMenuItemsForQuery(query) { + const q = (query || '').toLowerCase(); + const groups = SLASH_GROUPS + .map(({ section, showIn }) => ({ + section, + items: commandsFor(showIn).filter((i) => !q || i.label.toLowerCase().startsWith(q)), + })) + .filter((g) => g.items.length > 0); + return groups.flatMap(({ section, items }) => [{ section }, ...items]); +} diff --git a/blocks/canvas/editor-utils/command-helpers.js b/blocks/canvas/editor-utils/command-helpers.js new file mode 100644 index 000000000..c890f81ab --- /dev/null +++ b/blocks/canvas/editor-utils/command-helpers.js @@ -0,0 +1,214 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { + Fragment, + liftListItem, + setBlockType, + sinkListItem, + TextSelection, + toggleMark, + wrapIn, + wrapInList, +} from 'da-y-wrapper'; + +/* ---- Apply factories ---- */ + +export const blockType = (nodeKey, attrs) => (view) => { + const { state } = view; + setBlockType(state.schema.nodes[nodeKey], attrs)(state, view.dispatch.bind(view)); +}; + +export const wrap = (nodeKey) => (view) => { + const { state } = view; + wrapIn(state.schema.nodes[nodeKey])(state, view.dispatch.bind(view)); +}; + +export const list = (nodeKey) => (view) => { + const { state } = view; + wrapInList(state.schema.nodes[nodeKey])(state, view.dispatch.bind(view)); +}; + +export const inlineMark = (markKey) => (view) => { + const { state } = view; + toggleMark(state.schema.marks[markKey])(state, view.dispatch.bind(view)); +}; + +export const sinkListLevel = (view) => { + const { state } = view; + sinkListItem(state.schema.nodes.list_item)(state, view.dispatch.bind(view)); +}; + +export const liftListLevel = (view) => { + const { state } = view; + liftListItem(state.schema.nodes.list_item)(state, view.dispatch.bind(view)); +}; + +/* ---- Active queries ---- */ + +export function markIsActive(state, markName) { + const mark = state.schema.marks[markName]; + if (!mark) return false; + const { selection, storedMarks } = state; + if (selection.empty) { + return (storedMarks || selection.$from.marks()).some((m) => m.type === mark); + } + return state.doc.rangeHasMark(selection.from, selection.to, mark); +} + +export function inBlockquote($pos) { + for (let d = $pos.depth; d > 0; d -= 1) { + if ($pos.node(d).type.name === 'blockquote') return true; + } + return false; +} + +export function nearestListType($pos) { + for (let d = $pos.depth; d > 0; d -= 1) { + const { name } = $pos.node(d).type; + if (name === 'bullet_list' || name === 'ordered_list') return name; + } + return null; +} + +export function inList($pos) { + return nearestListType($pos) !== null; +} + +export function canSinkList(state) { + return sinkListItem(state.schema.nodes.list_item)(state); +} + +export function canLiftList(state) { + return liftListItem(state.schema.nodes.list_item)(state); +} + +/* ---- Slash-only action helpers ---- */ + +export function getTableHeading(schema) { + // eslint-disable-next-line camelcase + const { paragraph, table_row, table_cell } = schema.nodes; + const para = paragraph.create(null, schema.text('columns')); + // eslint-disable-next-line camelcase + return table_row.create(null, Fragment.from(table_cell.create({ colspan: 2 }, para))); +} + +export function getTableBody(schema) { + const cell = schema.nodes.table_cell.createAndFill(); + return schema.nodes.table_row.create(null, Fragment.fromArray([cell, cell])); +} + +export const LOREM_SENTENCES = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.', + 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.', + 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia.', + 'Nunc feugiat mi a tellus consequat imperdiet.', + 'Vestibulum sapien proin quam etiam ultrices suscipit gravida bibendum.', + 'Fusce pellentesque enim aliquam varius tincidunt aenean vulputate.', + 'Maecenas volutpat blandit aliquam etiam erat velit scelerisque in dictum.', +]; + +/* ---- Link queries ---- */ + +function findLinkInRange(state) { + const { from, to } = state.selection; + const linkType = state.schema.marks.link; + let found; + state.doc.nodesBetween(from, to, (node, pos) => { + if (found) return false; + const mark = linkType.isInSet(node.marks); + if (mark) { found = { node, mark, from: pos, to: pos + node.nodeSize }; } + return true; + }); + return found ?? null; +} + +export function selectionHasLink(state) { + return findLinkInRange(state) !== null; +} + +export function getLinkInfoInSelection(state) { + const result = findLinkInRange(state); + if (!result) return null; + return { + href: result.mark.attrs.href ?? '', + title: result.mark.attrs.title ?? '', + text: result.node.textContent, + from: result.from, + to: result.to, + }; +} + +/* ---- Link commands ---- */ + +export function applyLink(view, { href, text }) { + const { state } = view; + const { schema, selection } = state; + const linkType = schema.marks.link; + let { from, to } = selection; + let { tr } = state; + + const existingLink = findLinkInRange(state); + if (existingLink) { + ({ from, to } = existingLink); + tr = tr.removeMark(from, to, linkType); + } + + const displayText = text?.trim() || href; + const originalText = state.doc.textBetween(from, to); + + if (displayText !== originalText || from === to) { + const marks = from < state.doc.content.size + ? state.doc.resolve(from).marks().filter((m) => m.type !== linkType) + : []; + const textNode = schema.text(displayText, marks); + tr = tr.replaceWith(from, to, textNode); + to = from + displayText.length; + } + + tr = tr.addMark(from, to, linkType.create({ href: href.trim() })); + tr = tr.setSelection(TextSelection.create(tr.doc, to)); + view.dispatch(tr); +} + +export function removeLink(view) { + const { state } = view; + const linkType = state.schema.marks.link; + const found = findLinkInRange(state); + if (!found) return; + const { tr } = state; + tr.removeMark(found.from, found.to, linkType); + view.dispatch(tr); +} + +/* ---- Block-type picker value ---- */ + +const SCHEMA_NODE_TO_ID = new Map([ + ['paragraph', 'paragraph'], + ['code_block', 'code-block'], +]); + +function forEachTextblockInSelection({ doc, selection }, visit) { + doc.nodesBetween(selection.from, selection.to, (node) => { + if (node.isTextblock) { + visit(node); + return false; + } + return true; + }); +} + +export function getBlockTypePickerValue(state) { + const keys = []; + forEachTextblockInSelection(state, (node) => { + if (node.type.name === 'heading') { + keys.push(`heading-${node.attrs.level}`); + } else { + keys.push(SCHEMA_NODE_TO_ID.get(node.type.name) ?? node.type.name); + } + }); + const uniq = [...new Set(keys)]; + if (uniq.length === 0) return 'paragraph'; + if (uniq.length > 1) return 'mixed'; + return uniq[0]; +} diff --git a/blocks/canvas/editor-utils/document.js b/blocks/canvas/editor-utils/document.js new file mode 100644 index 000000000..882241876 --- /dev/null +++ b/blocks/canvas/editor-utils/document.js @@ -0,0 +1,155 @@ +/* eslint-disable import/no-unresolved -- prose2aem from da.live */ +import prose2aem from 'https://da.live/blocks/shared/prose2aem.js'; +/* eslint-enable import/no-unresolved */ + +const EDITABLES = [ + { selector: 'h1', nodeName: 'H1' }, + { selector: 'h2', nodeName: 'H2' }, + { selector: 'h3', nodeName: 'H3' }, + { selector: 'h4', nodeName: 'H4' }, + { selector: 'h5', nodeName: 'H5' }, + { selector: 'h6', nodeName: 'H6' }, + { selector: 'p', nodeName: 'P' }, + { selector: 'ol', nodeName: 'OL' }, + { selector: 'ul', nodeName: 'UL' }, +]; +const EDITABLE_SELECTORS = EDITABLES.map((edit) => edit.selector).join(', '); + +export function extractCursors(view) { + const remoteCursors = view.dom.querySelectorAll('.ProseMirror-yjs-cursor'); + const cursorMap = new Map(); + + remoteCursors.forEach((remoteCursor) => { + let highestEditable = null; + let current = remoteCursor.parentElement; + + while (current) { + if (current.matches?.(EDITABLE_SELECTORS)) { + highestEditable = current; + } + current = current.parentElement; + } + + if (!highestEditable) return; + + try { + const proseIndex = view.posAtDOM(highestEditable, 0); + cursorMap.set(proseIndex, { + proseIndex, + remote: remoteCursor.innerText, + color: remoteCursor.style['border-color'], + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not find position for remote cursor:', e); + } + }); + + return [...cursorMap.values()]; +} + +export function getInstrumentedHTML(view) { + const editorClone = view.dom.cloneNode(true); + + const originalElements = view.dom.querySelectorAll(EDITABLE_SELECTORS); + const clonedElements = editorClone.querySelectorAll(EDITABLE_SELECTORS); + + originalElements.forEach((originalElement, index) => { + if (clonedElements[index]) { + try { + const editableElementStartPos = view.posAtDOM(originalElement, 0); + clonedElements[index].setAttribute('data-prose-index', editableElementStartPos); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not find position for element:', e); + } + } + }); + + // Block instrumentation (same as da-nx qe-advanced): wrap blocks (e.g. tables), add a + // sentinel with data-prose-index, then after serialization move it to wrapper as data-block-index + const originalTables = view.dom.querySelectorAll('table'); + const clonedTables = editorClone.querySelectorAll('table'); + clonedTables.forEach((table, index) => { + const div = table.parentElement; + const blockMarker = document.createElement('div'); + blockMarker.className = 'block-marker'; + try { + const position = view.posAtDOM(originalTables[index], 0); + blockMarker.setAttribute('data-prose-index', position); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Could not find position for table block:', e); + } + div.insertAdjacentElement('beforebegin', blockMarker); + }); + + const remoteCursors = editorClone.querySelectorAll('.ProseMirror-yjs-cursor'); + + remoteCursors.forEach((remoteCursor) => { + let highestEditable = null; + let current = remoteCursor.parentElement; + + while (current) { + if (current.hasAttribute('data-prose-index')) { + highestEditable = current; + } + current = current.parentElement; + } + + if (highestEditable) { + highestEditable.setAttribute('data-cursor-remote', remoteCursor.innerText); + highestEditable.setAttribute('data-cursor-remote-color', remoteCursor.style['border-color']); + } + }); + + // Serialize clone to HTML, then move block-marker index onto wrapper as data-block-index + // (same pattern as da-nx qe-advanced: getInstrumentedHTML in prose2aem.js). + let htmlString = prose2aem(editorClone, true, false, true); + htmlString = htmlString.replace( + /
<\/div>\s*]*?)>/gi, + (_match, proseIndex, divAttributes) => ``, + ); + return htmlString; +} + +// 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. +export const editorSelectChange = (() => { + const listeners = new Set(); + return { + emit(detail) { listeners.forEach((fn) => fn(detail)); }, + 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 }); +} diff --git a/blocks/canvas/editor-utils/extensions-bridge.js b/blocks/canvas/editor-utils/extensions-bridge.js new file mode 100644 index 000000000..241701a39 --- /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/nx-selection-toolbar.css b/blocks/canvas/editor-utils/nx-selection-toolbar.css new file mode 100644 index 000000000..91efcaab2 --- /dev/null +++ b/blocks/canvas/editor-utils/nx-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/editor-utils/nx-selection-toolbar.js b/blocks/canvas/editor-utils/nx-selection-toolbar.js new file mode 100644 index 000000000..5dc7bffd2 --- /dev/null +++ b/blocks/canvas/editor-utils/nx-selection-toolbar.js @@ -0,0 +1,306 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { getNx, loadStyle } from '../../shared/nxutils.js'; + +await import(`${getNx()}/blocks/shared/popover/popover.js`); +await import(`${getNx()}/blocks/shared/picker/picker.js`); +import { commandsFor, COMMAND_BY_ID } from './command-defs.js'; +import { + getBlockTypePickerValue, + selectionHasLink, + getLinkInfoInSelection, + applyLink, + removeLink, +} from './command-helpers.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 LOCAL_ICONS = new Set([ + 'BlockCode', 'BlockQuote', 'Heading1', 'Heading2', 'Heading3', 'Heading4', 'Heading5', 'Heading6', + 'Rail', 'Separator', 'TableAdd', 'TextIndentIncrease', 'TextIndentDecrease', +]); + +function iconSrc(name) { + const file = `s2-icon-${name.toLowerCase()}-20-n.svg`; + return LOCAL_ICONS.has(name) ? `/blocks/canvas/img/${file}` : `/img/icons/${file}`; +} + +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 NxSelectionToolbar 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(name) { + 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('nx-selection-toolbar', NxSelectionToolbar); + +export default NxSelectionToolbar; diff --git a/blocks/canvas/editor-utils/preview.js b/blocks/canvas/editor-utils/preview.js new file mode 100644 index 000000000..35dfa62f3 --- /dev/null +++ b/blocks/canvas/editor-utils/preview.js @@ -0,0 +1,34 @@ +import { DA_CONTENT } from '../../shared/nxutils.js'; +import { daFetch } from '../../shared/utils.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/prose-diff.js b/blocks/canvas/editor-utils/prose-diff.js new file mode 100644 index 000000000..a643ef6cc --- /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 000000000..49ef85e5a --- /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('./nx-selection-toolbar.js'); + toolbar = document.createElement('nx-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('.nx-editor-doc'); + scrollEl?.addEventListener('scroll', onScroll, { passive: true }); + } + const header = document.querySelector('nx-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/editor-utils/state.js b/blocks/canvas/editor-utils/state.js new file mode 100644 index 000000000..c58bdec1b --- /dev/null +++ b/blocks/canvas/editor-utils/state.js @@ -0,0 +1,97 @@ +import { TextSelection } from 'da-y-wrapper'; + +/** + * Find where characters were inserted by comparing old and new text. + * Returns { start, end } as text offsets within the string, or null if + * no net insertion occurred. + */ +function findInsertedRange(oldText, newText) { + if (newText.length <= oldText.length) return null; + let prefixLen = 0; + const maxPrefix = Math.min(oldText.length, newText.length); + while (prefixLen < maxPrefix && oldText[prefixLen] === newText[prefixLen]) prefixLen += 1; + return { start: prefixLen, end: prefixLen + (newText.length - oldText.length) }; +} + +export function updateState(data, ctx) { + const { view } = ctx; + // Capture stored marks before the transaction — these are marks the user toggled + // (e.g. Bold) that ProseMirror is holding for the next character typed. In + // WYSIWYG mode, keystrokes go to the iframe so ProseMirror's normal mark + // application on input never runs; we must apply them ourselves here. + const { storedMarks } = view.state; + const node = view.state.schema.nodeFromJSON(data.node); + const pos = view.state.doc.resolve(data.cursorOffset); + const docPos = view.state.selection.from; + + const nodeStart = pos.before(pos.depth); + const nodeEnd = pos.after(pos.depth); + + const { tr } = view.state; + tr.replaceWith(nodeStart, nodeEnd, node); + + let appliedMarks = false; + if (storedMarks?.length) { + const oldText = view.state.doc.textBetween(nodeStart, nodeEnd); + const inserted = findInsertedRange(oldText, node.textContent); + if (inserted) { + // In ProseMirror each text character occupies one position unit, so + // text offset i maps to doc position nodeStart + 1 + i. + const markFrom = nodeStart + 1 + inserted.start; + const markTo = nodeStart + 1 + inserted.end; + storedMarks.forEach((mark) => tr.addMark(markFrom, markTo, mark)); + // Preserve stored marks so continued typing stays in the same formatting state. + tr.setStoredMarks(storedMarks); + appliedMarks = true; + } + } + + tr.setSelection(TextSelection.create(tr.doc, docPos)); + + ctx.suppressRerender = true; + view.dispatch(tr); + ctx.suppressRerender = false; + + // Sync the updated node (with marks applied) back to the portal's mini editor. + // Without this, the portal's editor retains the plain-text version, so the next + // character typed would send a node-update that overwrites the marks we just added + // (replaceWith replaces the whole paragraph with the portal's plain content). + if (appliedMarks && ctx.port) { + try { + const syncPos = view.state.doc.resolve(data.cursorOffset); + const syncNodeStart = syncPos.before(syncPos.depth); + const syncNode = view.state.doc.resolve(syncNodeStart).nodeAfter; + if (syncNode) { + ctx.port.postMessage({ + type: 'set-editor-state', + editorState: syncNode.toJSON(), + cursorOffset: data.cursorOffset, + }); + } + } catch { + // Non-fatal: position errors after structural changes + } + } +} + +export function getEditor(data, ctx) { + if (ctx.suppressRerender) return; + const { view } = ctx; + const { cursorOffset } = data; + if (typeof cursorOffset !== 'number') return; + + const { doc } = view.state; + const maxPos = doc.content.size; + if (cursorOffset < 0 || cursorOffset > maxPos) return; + + try { + const pos = doc.resolve(cursorOffset); + const before = pos.before(pos.depth); + const beforePos = doc.resolve(before); + const nodeAtBefore = beforePos.nodeAfter; + if (!nodeAtBefore) return; + ctx.port.postMessage({ type: 'set-editor-state', editorState: nodeAtBefore.toJSON(), cursorOffset: before + 1 }); + } catch { + // Stale iframe cursor after structural replace (e.g. chat revert, remote sync). + } +} 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 000000000..f1b75494f --- /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 000000000..9d3034cf5 --- /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-gridcompare-20-n.svg b/blocks/canvas/img/s2-icon-gridcompare-20-n.svg new file mode 100644 index 000000000..ccf76e060 --- /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 000000000..266cff49f --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading1-20-n.svg @@ -0,0 +1,5 @@ + + + 1 + + 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 000000000..6453ca1db --- /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 000000000..9a2d44484 --- /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 000000000..b27772481 --- /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 000000000..802c6554f --- /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 000000000..dcc49ff44 --- /dev/null +++ b/blocks/canvas/img/s2-icon-heading6-20-n.svg @@ -0,0 +1,5 @@ + + + + + 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 000000000..b70a99e24 --- /dev/null +++ b/blocks/canvas/img/s2-icon-rail-20-n.svg @@ -0,0 +1,14 @@ + + + + + + S Rail 18 N + + + + \ 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 000000000..c357171ff --- /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 000000000..3c51c414d --- /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 000000000..68810c74a --- /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 000000000..31a90ff85 --- /dev/null +++ b/blocks/canvas/img/s2-icon-tableadd-20-n.svg @@ -0,0 +1,6 @@ + + + + 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 000000000..d3196ded2 --- /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 000000000..966cf0f69 --- /dev/null +++ b/blocks/canvas/img/s2-icon-textindentincrease-20-n.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/blocks/canvas/nx-canvas-header/nx-canvas-header.css b/blocks/canvas/nx-canvas-header/nx-canvas-header.css new file mode 100644 index 000000000..826ffa768 --- /dev/null +++ b/blocks/canvas/nx-canvas-header/nx-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(--nx-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/nx-canvas-header/nx-canvas-header.js b/blocks/canvas/nx-canvas-header/nx-canvas-header.js new file mode 100644 index 000000000..b8f72fad0 --- /dev/null +++ b/blocks/canvas/nx-canvas-header/nx-canvas-header.js @@ -0,0 +1,133 @@ +import { LitElement, html } from 'da-lit'; + +import { loadStyle } from '../../shared/nxutils.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 NXCanvasHeader 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('nx-canvas-header', NXCanvasHeader); diff --git a/blocks/canvas/nx-editor-doc/nx-editor-doc.css b/blocks/canvas/nx-editor-doc/nx-editor-doc.css new file mode 100644 index 000000000..373dda075 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/nx-editor-doc.css @@ -0,0 +1,488 @@ +/* stylelint-disable selector-class-pattern -- ProseMirror and Yjs use their own class names */ + +.nx-editor-doc { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden auto; +} + +.nx-editor-doc .nx-editor-doc-mount { + flex: 1; + min-height: 0; + max-width: 800px; + margin: auto; +} + +.nx-editor-doc .da-prose-mirror { + flex: 1; + min-height: 0; + position: relative; +} + +.nx-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; +} + +.nx-editor-doc .ProseMirror-focused { + outline: none; +} + +.nx-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; +} + +.nx-editor-doc .ProseMirror > *:first-child { + margin-top: 0; +} + +.nx-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. */ +.nx-editor-doc .ProseMirror table { + border-collapse: collapse; + table-layout: fixed; + width: 100% !important; + overflow: hidden; + border-style: hidden; +} + +.nx-editor-doc .ProseMirror td, +.nx-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; +} + +.nx-editor-doc .ProseMirror tr:first-child td, +.nx-editor-doc .ProseMirror tr:first-child th { + background: light-dark(#f1f1f1, var(--s2-gray-200, #3d3d3d)); + text-align: center; + font-weight: 700; +} + +.nx-editor-doc .ProseMirror td.selectedCell, +.nx-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%); +} + +.nx-editor-doc .ProseMirror tr:first-child td.selectedCell, +.nx-editor-doc .ProseMirror tr:first-child th.selectedCell { + background: light-dark(#d1e4f8, rgb(20 115 230 / 35%)); +} + +.nx-editor-doc .ProseMirror td > *:first-child, +.nx-editor-doc .ProseMirror th > *:first-child { + margin-top: 0; +} + +.nx-editor-doc .ProseMirror td > *:last-child, +.nx-editor-doc .ProseMirror th > *:last-child { + margin-bottom: 0; +} + +.nx-editor-doc p code, +.nx-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; +} + +.nx-editor-doc .ProseMirror pre { + white-space: pre-wrap; +} + +.nx-editor-doc blockquote { + position: relative; + padding: 0 0.5rem 0 1.5rem; + margin: 0; +} + +.nx-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; +} + +.nx-editor-doc .ProseMirror-hideselection *::selection { + background: transparent; +} + +.nx-editor-doc .ProseMirror-selectednode { + outline: 2px solid var(--s2-blue-800, #1473e6); +} + +.nx-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; + } +} + +.nx-editor-doc .ProseMirror .tableWrapper { + overflow-x: auto; + border: 2px solid light-dark(#b1b1b1, var(--s2-gray-400, #6e6e6e)); + border-radius: 6px; + margin: 2px 0; +} + +.nx-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; +} + +.nx-editor-doc .ProseMirror.resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +.nx-editor-doc .ProseMirror td > *:has(+ .column-resize-handle), +.nx-editor-doc .ProseMirror th > *:has(+ .column-resize-handle) { + margin-bottom: 0; +} + +.nx-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; +} + +.nx-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; +} + +.nx-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; +} + +.nx-editor-doc .nx-editor-doc-placeholder code { + background: light-dark(#e5e5e5, var(--s2-gray-200, #3d3d3d)); + padding: 0.125rem 0.375rem; + border-radius: 2px; +} + +.nx-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 */ + +.nx-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; +} + +.nx-editor-doc .table-select-handle.is-visible { + display: flex; +} + +.nx-editor-doc .table-select-handle:hover { + background-color: light-dark(#f0f7ff, rgb(20 115 230 / 20%)); + border-color: var(--s2-blue-800, #1473e6); +} + +.nx-editor-doc .focal-point-image-wrapper { + position: relative; + display: block; +} + +.nx-editor-doc .focal-point-image-wrapper img { + display: block; + position: relative; +} + +.nx-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; +} + +.nx-editor-doc .focal-point-icon-active { + opacity: 1; +} + +.nx-editor-doc .focal-point-icon:hover { + background: light-dark(#fff, var(--s2-gray-75, #3d3d3d)); + border-color: var(--s2-blue-800, #1473e6); +} + +.nx-editor-doc .focal-point-image-wrapper:hover .focal-point-icon:not(.focal-point-icon-active) { + opacity: 1; +} + +.nx-editor-doc .focal-point-icon svg { + color: light-dark(#505050, var(--s2-gray-700, #cacaca)); +} + +.nx-editor-doc .focal-point-icon svg .fill { + fill: currentcolor; +} + +.nx-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/nx-editor-doc/nx-editor-doc.js b/blocks/canvas/nx-editor-doc/nx-editor-doc.js new file mode 100644 index 000000000..2470fcfcf --- /dev/null +++ b/blocks/canvas/nx-editor-doc/nx-editor-doc.js @@ -0,0 +1,313 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper'; +import { loadStyle } from '../../shared/nxutils.js'; +import { updateDocument, updateCursors, getInstrumentedHTML, editorHtmlChange, editorSelectChange } from '../editor-utils/document.js'; +import { getActiveBlockFlatIndex, getBlockPositions } from '../nx-editor-wysiwyg/utils/blocks.js'; +import { getEditor } from '../editor-utils/state.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 style = await loadStyle(import.meta.url); + +export class NxEditorDoc 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._lastDocBlockFlatIndex = 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(blockFlatIndex) { + if (blockFlatIndex < 0) return; + const { view } = this._proseContext ?? {}; + if (!view) return; + const positions = getBlockPositions(view); + const pos = positions[blockFlatIndex]; + if (pos == null) return; + this._lastDocBlockFlatIndex = blockFlatIndex; + 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, + lastBlockFlatIndex: 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('.nx-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 flatIndex = getActiveBlockFlatIndex(pmView); + if (flatIndex === this._lastDocBlockFlatIndex) return; + this._lastDocBlockFlatIndex = flatIndex; + editorSelectChange.emit({ blockFlatIndex: flatIndex, source: 'doc' }); + }, + ), + ], + }); + + 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(({ blockFlatIndex, source }) => { + if (source !== 'doc') this._scrollDocToBlock(blockFlatIndex); + }); + } + + 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` +
+
${this._error}
+
+ `; + } + if (phase === 'loading') { + return nothing; + } + return html` +
+
+
+ `; + } +} + +customElements.define('nx-editor-doc', NxEditorDoc); diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js new file mode 100644 index 000000000..e5f504c14 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js @@ -0,0 +1,82 @@ +import { Plugin } from 'da-y-wrapper'; +import { DA_ADMIN, DA_CONTENT } from '../../../shared/nxutils.js'; +import { daFetch } from '../../../shared/utils.js'; +import { getSourceUploadContext } from './sourceUploadContext.js'; + +const FPO_IMG_URL = 'https://da.live/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/nx-editor-doc/prose-plugins/codemark.js b/blocks/canvas/nx-editor-doc/prose-plugins/codemark.js new file mode 100644 index 000000000..f39b3808a --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/codemark.js @@ -0,0 +1,50 @@ +// Derived from: https://github.com/curvenote/editor/blob/812893edbf66e7903226ff73ea1c1f3234cd483b/packages/prosemirror-codemark/src/inputRules.ts + +import { Plugin, TextSelection } from 'da-y-wrapper'; + +function hasMark(markType, state, from, to) { + if (!markType) return false; + if (state.storedMarks && markType.isInSet(state.storedMarks)) return true; + if (markType.isInSet(state.doc.resolve(from).marks())) return true; + return state.doc.rangeHasMark(from, to, markType); +} + +function markText(view, match, from, to) { + const { state } = view; + const markType = state.schema.marks.code; + if (hasMark(markType, state, from, to)) return false; + const tr = state.tr.delete(from, to); + const { anchor } = tr.selection; + tr.insertText(match[1]) + .addMark(anchor, anchor + match[1].length, markType.create()) + .setSelection(TextSelection.create(tr.doc, anchor + match[1].length)) + .removeStoredMark(markType); + view.dispatch(tr); + return true; +} + +export default function codemark() { + return new Plugin({ + props: { + handleTextInput: (view, from, to, text) => { + if (text !== '`') return false; + const { state } = view; + const { $cursor } = state.selection; + if (!$cursor) return false; + const $from = state.doc.resolve(from); + const { parent } = $from; + const before = parent.textBetween(0, $from.parentOffset); + let match = before.match(/`((?:[^`\w]|[\w])+)$/); + if (match) { + return markText(view, match, from - match[0].length, to); + } + const after = parent.textBetween($from.parentOffset, parent.nodeSize - 2); + match = after.match(/^((?:[^`\w]|[\w])+)`/); + if (match) { + return markText(view, match, from, to + match[0].length); + } + return false; + }, + }, + }); +} diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/focalPointDialog.js b/blocks/canvas/nx-editor-doc/prose-plugins/focalPointDialog.js new file mode 100644 index 000000000..7185a5e62 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/focalPointDialog.js @@ -0,0 +1,243 @@ +let currentDialog = null; + +function hasFocalPointData(attrs) { + return (attrs.dataFocalX && attrs.dataFocalX !== '') + || (attrs.dataFocalY && attrs.dataFocalY !== ''); +} + +function createCoordinateInput(label, value) { + const labelEl = document.createElement('label'); + labelEl.textContent = `${label}: `; + const input = document.createElement('input'); + input.type = 'text'; + input.value = `${parseFloat(String(value)).toFixed(2)}%`; + input.className = 'focal-point-input'; + input.disabled = true; + labelEl.appendChild(input); + return { labelEl, input }; +} + +function cleanupEventListeners(handleMouseMove, handleMouseUp) { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); +} + +function updateNodeFocalPoint(view, pos, node, x, y) { + const docNode = view.state.doc.nodeAt(pos); + if (!docNode || docNode.type.name !== node.type.name || docNode.attrs.src !== node.attrs.src) { + return; + } + + const tr = view.state.tr.setNodeMarkup(pos, null, { + ...node.attrs, + dataFocalX: x != null && x !== '' ? Number(x).toFixed(2) : null, + dataFocalY: y != null && y !== '' ? Number(y).toFixed(2) : null, + }); + view.dispatch(tr); +} + +const parseCoord = (val) => (val ? parseFloat(val) : null); + +/** + * @param {import('prosemirror-view').EditorView} view + * @param {number} pos + * @param {import('prosemirror-model').Node} node + */ +export function openFocalPointDialog(view, pos, node) { + if (currentDialog) { + currentDialog.remove(); + currentDialog = null; + } + + const snapshotX = parseCoord(node.attrs.dataFocalX); + const snapshotY = parseCoord(node.attrs.dataFocalY); + + let currentX = snapshotX ?? 50; + let currentY = snapshotY ?? 50; + + const dialog = document.createElement('dialog'); + dialog.className = 'nx-focal-point-dialog'; + + const title = document.createElement('h2'); + title.className = 'nx-focal-point-dialog__title'; + title.textContent = 'Set Image Focal Point'; + + const content = document.createElement('div'); + content.className = 'focal-point-content'; + + const imageContainer = document.createElement('div'); + imageContainer.className = 'focal-point-image-container'; + + const img = document.createElement('img'); + img.crossOrigin = 'anonymous'; + img.src = node.attrs.src; + img.className = 'focal-point-image'; + img.draggable = false; + + const indicator = document.createElement('div'); + indicator.className = 'focal-point-indicator'; + + const inner = document.createElement('div'); + inner.className = 'focal-point-inner'; + + indicator.append(inner); + + imageContainer.appendChild(img); + + img.addEventListener('load', () => { + imageContainer.appendChild(indicator); + }); + + const coordsContainer = document.createElement('div'); + coordsContainer.className = 'focal-point-coords'; + + const { labelEl: xLabel, input: xInput } = createCoordinateInput('X', currentX); + const { labelEl: yLabel, input: yInput } = createCoordinateInput('Y', currentY); + + coordsContainer.appendChild(xLabel); + coordsContainer.appendChild(yLabel); + + content.appendChild(imageContainer); + content.appendChild(coordsContainer); + + const footer = document.createElement('div'); + footer.className = 'nx-focal-point-dialog__footer'; + + const acceptBtn = document.createElement('button'); + acceptBtn.type = 'button'; + acceptBtn.className = 'nx-focal-point-dialog__btn nx-focal-point-dialog__btn--accent'; + acceptBtn.textContent = 'Accept'; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'nx-focal-point-dialog__btn'; + cancelBtn.textContent = 'Cancel'; + + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'nx-focal-point-dialog__btn nx-focal-point-dialog__btn--danger'; + clearBtn.textContent = 'Clear Focal Point'; + + if (!hasFocalPointData(node.attrs)) { + clearBtn.disabled = true; + } + + footer.append(cancelBtn, clearBtn, acceptBtn); + + dialog.append(title, content, footer); + + let isDragging = false; + /** @type {'pending' | 'accept' | 'cancel' | 'clear'} */ + let outcome = 'pending'; + + const updateIndicatorPosition = (x, y) => { + const imgRect = img.getBoundingClientRect(); + const containerRect = imageContainer.getBoundingClientRect(); + + const imgLeft = imgRect.left - containerRect.left; + const imgTop = imgRect.top - containerRect.top; + + const pixelX = imgLeft + ((imgRect.width * x) / 100); + const pixelY = imgTop + ((imgRect.height * y) / 100); + + indicator.style.left = `${pixelX}px`; + indicator.style.top = `${pixelY}px`; + xInput.value = `${x.toFixed(2)}%`; + yInput.value = `${y.toFixed(2)}%`; + }; + + const updatePositionFromEvent = (e) => { + const rect = img.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + + currentX = Math.max(0, Math.min(100, x)); + currentY = Math.max(0, Math.min(100, y)); + + updateIndicatorPosition(currentX, currentY); + }; + + const handleMouseDown = (e) => { + if (e.target === img || e.target === imageContainer) { + isDragging = true; + imageContainer.style.cursor = 'grabbing'; + e.preventDefault(); + updatePositionFromEvent(e); + updateNodeFocalPoint(view, pos, node, currentX, currentY); + } + }; + + const handleMouseMove = (e) => { + if (!isDragging) return; + updatePositionFromEvent(e); + }; + + const handleMouseUp = () => { + if (isDragging) { + isDragging = false; + imageContainer.style.cursor = ''; + updateNodeFocalPoint(view, pos, node, currentX, currentY); + } + }; + + imageContainer.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + const finish = () => { + cleanupEventListeners(handleMouseMove, handleMouseUp); + currentDialog = null; + dialog.remove(); + view.focus(); + }; + + dialog.addEventListener('cancel', () => { + outcome = 'cancel'; + }); + + dialog.addEventListener('close', () => { + if (outcome === 'cancel' || outcome === 'pending') { + updateNodeFocalPoint(view, pos, node, snapshotX, snapshotY); + } else if (outcome === 'accept') { + updateNodeFocalPoint(view, pos, node, currentX, currentY); + } + finish(); + }); + + acceptBtn.addEventListener('click', () => { + outcome = 'accept'; + dialog.close(); + }); + + cancelBtn.addEventListener('click', () => { + outcome = 'cancel'; + dialog.close(); + }); + + clearBtn.addEventListener('click', () => { + outcome = 'clear'; + updateNodeFocalPoint(view, pos, node, null, null); + dialog.close(); + }); + + document.body.appendChild(dialog); + currentDialog = dialog; + dialog.showModal(); + + const positionIndicator = () => { + const update = () => { + requestAnimationFrame(() => { + updateIndicatorPosition(currentX, currentY); + }); + }; + + if (img.complete && img.naturalWidth > 0) { + update(); + } else { + img.addEventListener('load', update); + img.addEventListener('error', update); + } + }; + + positionIndicator(); +} diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/nx-editor-doc/prose-plugins/imageDrop.js new file mode 100644 index 000000000..bf820d5b1 --- /dev/null +++ b/blocks/canvas/nx-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 = 'https://da.live/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/nx-editor-doc/prose-plugins/imageFocalPoint.js b/blocks/canvas/nx-editor-doc/prose-plugins/imageFocalPoint.js new file mode 100644 index 000000000..1c0917a61 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/imageFocalPoint.js @@ -0,0 +1,154 @@ +import { Plugin } from 'da-y-wrapper'; +import { openFocalPointDialog } from './focalPointDialog.js'; +import { getTableInfo, isInTableCell } from './tableUtils.js'; + +let blocksDataPromise = null; +async function getBlocksData() { + if (!blocksDataPromise) { + blocksDataPromise = (async () => { + try { + const { getLibraryList } = await import('https://da.live/blocks/edit/da-library/helpers/helpers.js'); + const { getBlocks } = await import('https://da.live/blocks/edit/da-library/helpers/index.js'); + const libraryList = await getLibraryList(); + const blocksInfo = libraryList.find((l) => l.name === 'blocks'); + if (!blocksInfo) return []; + return await getBlocks(blocksInfo.sources); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to load blocks data for focal point:', error); + return []; + } + })(); + } + return blocksDataPromise; +} + +function hasFocalPointData(attrs) { + return (attrs.dataFocalX && attrs.dataFocalX !== '') + || (attrs.dataFocalY && attrs.dataFocalY !== ''); +} + +function shouldShowFocalPoint(tableName, blocks) { + if (!tableName || !blocks || blocks.length === 0) return false; + + const tableNameLower = tableName.toLowerCase().replace(/-/g, ' '); + return blocks.some((block) => (block.name.toLowerCase() === tableNameLower && block['focal-point'] === 'yes')); +} + +function updateImageAttributes(img, attrs) { + img.src = attrs.src; + ['alt', 'title', 'width', 'height'].forEach((attr) => { + if (attrs[attr]) { + img[attr] = attrs[attr]; + } else { + img.removeAttribute(attr); + } + }); + + if (attrs.dataFocalX && attrs.dataFocalY) { + img.setAttribute('data-focal-x', attrs.dataFocalX); + img.setAttribute('data-focal-y', attrs.dataFocalY); + } else { + img.removeAttribute('data-focal-x'); + img.removeAttribute('data-focal-y'); + if (img.title?.includes('data-focal:')) { + img.removeAttribute('title'); + } + } +} + +class ImageWithFocalPointView { + constructor(node, view, getPos) { + this.node = node; + this.view = view; + this.getPos = getPos; + + this.dom = document.createElement('span'); + this.dom.className = 'focal-point-image-wrapper'; + + this.img = document.createElement('img'); + updateImageAttributes(this.img, node.attrs); + + this.dom.appendChild(this.img); + + this.initFocalPoint(); + } + + async initFocalPoint() { + try { + const blocks = await getBlocksData(); + const pos = this.getPos(); + if (pos == null) return; + + const tableInfo = getTableInfo(this.view.state, pos); + if (tableInfo && shouldShowFocalPoint(tableInfo.tableName, blocks)) { + this.enableFocalPoint(); + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to initialize focal point:', error); + } + } + + enableFocalPoint() { + if (this.icon) return; + + this.icon = document.createElement('span'); + this.icon.className = hasFocalPointData(this.node.attrs) + ? 'focal-point-icon focal-point-icon-active' + : 'focal-point-icon'; + + const crosshairs = document.createElement('img'); + crosshairs.src = '/blocks/edit/img/Smock_Crosshairs_18_N.svg'; + crosshairs.setAttribute('aria-hidden', 'true'); + this.icon.append(crosshairs); + + this.handleIconClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const pos = this.getPos(); + if (pos != null) { + openFocalPointDialog(this.view, pos, this.node); + } + }; + this.icon.addEventListener('click', this.handleIconClick); + + this.dom.appendChild(this.icon); + } + + update(node) { + if (node.type.name !== 'image') return false; + + this.node = node; + updateImageAttributes(this.img, node.attrs); + + if (this.icon) { + this.icon.className = hasFocalPointData(node.attrs) + ? 'focal-point-icon focal-point-icon-active' + : 'focal-point-icon'; + } + + return true; + } + + destroy() { + if (this.icon) { + this.icon.removeEventListener('click', this.handleIconClick); + } + } +} + +export default function imageFocalPoint() { + return new Plugin({ + props: { + nodeViews: { + image(node, view, getPos) { + if (isInTableCell(view.state, getPos())) { + return new ImageWithFocalPointView(node, view, getPos); + } + return null; + }, + }, + }, + }); +} diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/inlinesvg.js b/blocks/canvas/nx-editor-doc/prose-plugins/inlinesvg.js new file mode 100644 index 000000000..4b914a485 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/inlinesvg.js @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * Ported from da.live blocks/shared/inlinesvg.js + */ +async function fetchIcon(path) { + const resp = await fetch(path); + if (!resp.ok) return null; + const text = await resp.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(text, 'image/svg+xml'); + return doc.querySelector('svg'); +} + +export default function inlinesvg({ parent, paths }) { + const svgs = paths.map(async (path) => { + const svg = await fetchIcon(path); + if (parent && svg) parent.append(svg); + return svg; + }); + return Promise.all(svgs); +} diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/sectionPasteHandler.js b/blocks/canvas/nx-editor-doc/prose-plugins/sectionPasteHandler.js new file mode 100644 index 000000000..f1a3e57e8 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/sectionPasteHandler.js @@ -0,0 +1,158 @@ +import { Fragment, Plugin, Slice } from 'da-y-wrapper'; + +function closeParagraph(paraContent, newContent) { + if (paraContent.length > 0) { + const newPara = { + type: 'paragraph', + content: [...paraContent], + }; + newContent.push(newPara); + paraContent.length = 0; + } +} + +function handleDesktopWordSectionBreaks(doc) { + if (doc.querySelector('meta[name="ProgId"]')?.content !== 'Word.Document') { + return false; + } + + let modified = false; + const sections = doc.querySelectorAll('body > div'); + sections.forEach((section) => { + if (section.nextElementSibling) { + section.after(doc.createElement('hr')); + modified = true; + } + }); + + return modified; +} + +function handleWordOnlineSectionBreaks(doc) { + let modified = false; + const sections = doc.querySelectorAll('div > p > span[data-ccp-props]'); + sections.forEach((section) => { + const props = JSON.parse(section.getAttribute('data-ccp-props')); + for (const key of Object.keys(props)) { + if (props[key] === 'single') { + const hr = doc.createElement('hr'); + section.parentNode.after(hr); + modified = true; + break; + } + } + }); + + return modified; +} + +function isBlankLineDiv(div) { + return [...div.childNodes].every( + (node) => (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') + || node.nodeName === 'BR', + ); +} + +function handleDivLineBreaks(doc) { + const divs = doc.querySelectorAll('body > div'); + if (divs.length === 0) return false; + + const hasBlankLine = [...divs].some(isBlankLineDiv); + if (!hasBlankLine) return false; + + divs.forEach((div) => { + const p = doc.createElement('p'); + if (!isBlankLineDiv(div)) { + while (div.firstChild) { + p.appendChild(div.firstChild); + } + } + div.replaceWith(p); + }); + + return true; +} + +export default function sectionPasteHandler(schema) { + return new Plugin({ + props: { + clipboardTextParser: (text) => { + const lines = text.split(/\r\n?|\n/); + const nodes = lines.map((line) => { + if (line.length === 0) return schema.nodes.paragraph.create(); + return schema.nodes.paragraph.create(null, [schema.text(line)]); + }); + return new Slice(Fragment.from(nodes), 1, 1); + }, + + transformPastedHTML: (html) => { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + let modified = handleDesktopWordSectionBreaks(doc); + if (!modified) { + modified = handleWordOnlineSectionBreaks(doc); + } + if (!modified) { + modified = handleDivLineBreaks(doc); + } + + if (!modified) { + return html; + } + + const serializer = new XMLSerializer(); + return serializer.serializeToString(doc); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error handling Word section breaks:', error); + return html; + } + }, + + transformPasted: (slice) => { + const jslice = slice.toJSON(); + if (!jslice) return slice; + const { content } = jslice; + if (!content) return slice; + + const newContent = []; + + for (const el of content) { + if (el.type !== 'paragraph') { + newContent.push(el); + } else { + const newParaCont = []; + + if (!el.content) { + newContent.push({ type: 'paragraph', content: [] }); + } else { + for (const pc of el.content) { + if (pc.type !== 'text') { + newParaCont.push(pc); + } else if (pc.text.trim() === '---') { + closeParagraph(newParaCont, newContent); + + newContent.push({ type: 'horizontal_rule' }); + } else { + newParaCont.push(pc); + } + } + + closeParagraph(newParaCont, newContent); + } + } + } + + const newSlice = { + content: newContent, + openStart: slice.openStart, + openEnd: slice.openEnd, + }; + + return Slice.fromJSON(schema, newSlice); + }, + }, + }); +} diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js new file mode 100644 index 000000000..64042f67e --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails). + */ +import { DA_ADMIN } from '../../../shared/nxutils.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/nx-editor-doc/prose-plugins/tableSelectHandle.js b/blocks/canvas/nx-editor-doc/prose-plugins/tableSelectHandle.js new file mode 100644 index 000000000..304a87bfd --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/tableSelectHandle.js @@ -0,0 +1,135 @@ +import { Plugin, NodeSelection } from 'da-y-wrapper'; + +const HANDLE_OFFSET = 6; + +function getTablePos(view, tableEl) { + const pos = view.posAtDOM(tableEl, 0); + + if (pos === null) { + return null; + } + + const $pos = view.state.doc.resolve(pos); + for (let d = $pos.depth; d >= 0; d -= 1) { + if ($pos.node(d).type.name === 'table') { + return $pos.before(d); + } + } + + return null; +} + +/** + * Allows selecting an entire table by clicking an icon in the top left corner. + */ +export default function tableSelectHandle() { + let handle = null; + let currentTable = null; + let currentWrapper = null; + + function showHandle(wrapper, editorRect) { + if (!handle || !wrapper) { + return; + } + const rect = wrapper.getBoundingClientRect(); + handle.style.left = `${rect.left - editorRect.left + HANDLE_OFFSET}px`; + handle.style.top = `${rect.top - editorRect.top + HANDLE_OFFSET}px`; + handle.classList.add('is-visible'); + } + + function hideHandle() { + handle?.classList.remove('is-visible'); + currentTable = null; + currentWrapper = null; + } + + function createHandle(view) { + const el = document.createElement('div'); + el.className = 'table-select-handle'; + el.contentEditable = 'false'; + + el.addEventListener('mousedown', (e) => { + if (!currentTable) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const tablePos = getTablePos(view, currentTable); + + if (tablePos !== null) { + const sel = NodeSelection.create(view.state.doc, tablePos); + view.dispatch(view.state.tr.setSelection(sel)); + view.focus(); + } + }); + + el.addEventListener('mouseleave', (e) => { + if (e.relatedTarget && currentWrapper?.contains(e.relatedTarget)) { + return; + } + + hideHandle(); + }); + + return el; + } + + return new Plugin({ + view(editorView) { + handle = createHandle(editorView); + const container = editorView.dom.parentElement; + + if (container) { + container.appendChild(handle); + } + + const onMouseOver = (e) => { + const wrapper = e.target.closest('.tableWrapper'); + + if (!wrapper || wrapper === currentWrapper) { + return; + } + + currentWrapper = wrapper; + currentTable = wrapper.querySelector('table'); + const editorRect = editorView.dom.getBoundingClientRect(); + showHandle(wrapper, editorRect); + }; + + const onMouseOut = (e) => { + const wrapper = e.target.closest('.tableWrapper'); + + if (!wrapper) { + return; + } + + const related = e.relatedTarget; + + if (related === handle || wrapper.contains(related)) { + return; + } + + hideHandle(); + }; + + editorView.dom.addEventListener('mouseover', onMouseOver); + editorView.dom.addEventListener('mouseout', onMouseOut); + + return { + update() { + if (currentWrapper && !currentWrapper.isConnected) { + hideHandle(); + } + }, + destroy() { + editorView.dom.removeEventListener('mouseover', onMouseOver); + editorView.dom.removeEventListener('mouseout', onMouseOut); + handle?.remove(); + handle = null; + }, + }; + }, + }); +} diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/tableUtils.js b/blocks/canvas/nx-editor-doc/prose-plugins/tableUtils.js new file mode 100644 index 000000000..e272c44d3 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose-plugins/tableUtils.js @@ -0,0 +1,47 @@ +export function getTableInfo(state, pos) { + const $pos = state.doc.resolve(pos); + let tableCellDepth = -1; + + for (let d = $pos.depth; d > 0; d -= 1) { + const node = $pos.node(d); + if (node.type.name === 'table_cell') { + tableCellDepth = d; + break; + } + } + + if (tableCellDepth === -1) return null; + + const rowDepth = tableCellDepth - 1; + const tableDepth = rowDepth - 1; + const table = $pos.node(tableDepth); + const firstRow = table.child(0); + const cellIndex = $pos.index(tableCellDepth - 1); + const row = $pos.node(rowDepth); + + const firstRowContent = firstRow.child(0).textContent; + const tableNameMatch = firstRowContent.match(/^([a-zA-Z0-9_\s-]+)(?:\s*\([^)]*\))?$/); + + if (!tableNameMatch) return null; + + const currentRowFirstColContent = (row.childCount > 1 && cellIndex === 1) + ? row.child(0).textContent + : null; + + return { + tableName: tableNameMatch[1].trim(), + keyValue: currentRowFirstColContent, + isFirstColumn: cellIndex === 0, + columnsInRow: row.childCount, + }; +} + +export function isInTableCell(state, pos) { + const $pos = state.doc.resolve(pos); + for (let d = $pos.depth; d > 0; d -= 1) { + if ($pos.node(d).type.name === 'table_cell') { + return true; + } + } + return false; +} diff --git a/blocks/canvas/nx-editor-doc/prose.js b/blocks/canvas/nx-editor-doc/prose.js new file mode 100644 index 000000000..f09d54d14 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/prose.js @@ -0,0 +1,171 @@ +/* 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 { + getEnterInputRulesPlugin, + getURLInputRulesPlugin, + getListInputRulesPlugin, + handleTableBackspace, + handleTableTab, +} from 'https://da.live/blocks/edit/prose/plugins/keyHandlers.js'; +import { getHeadingKeymap } from 'https://da.live/blocks/edit/prose/plugins/menu/menu.js'; +import { getSchema } from 'da-parser'; +import { createSlashMenuPlugin } from './slash-menu/slash-menu.js'; +import { createSelectionToolbarPlugin } from '../editor-utils/selection-toolbar.js'; +import codemark from './prose-plugins/codemark.js'; +import tableSelectHandle from './prose-plugins/tableSelectHandle.js'; +import imageDrop from './prose-plugins/imageDrop.js'; +import imageFocalPoint from './prose-plugins/imageFocalPoint.js'; +import sectionPasteHandler from './prose-plugins/sectionPasteHandler.js'; +import base64Uploader from './prose-plugins/base64Uploader.js'; +import { DA_ADMIN, DA_COLLAB } from '../../shared/nxutils.js'; +import { generateColor, getCollabIdentity } from './utils/collab.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/nx-editor-doc/slash-menu/slash-menu.js b/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js new file mode 100644 index 000000000..18f9f9632 --- /dev/null +++ b/blocks/canvas/nx-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 '../../../shared/nxutils.js'; + +await import(`${getNx()}/blocks/shared/menu/menu.js`); +import { slashMenuItemsForQuery, COMMAND_BY_ID } from '../../editor-utils/command-defs.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('.nx-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/nx-editor-doc/utils/awareness-users.js b/blocks/canvas/nx-editor-doc/utils/awareness-users.js new file mode 100644 index 000000000..17eaf340f --- /dev/null +++ b/blocks/canvas/nx-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/nx-editor-doc/utils/collab.js b/blocks/canvas/nx-editor-doc/utils/collab.js new file mode 100644 index 000000000..985ffc036 --- /dev/null +++ b/blocks/canvas/nx-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/nx-editor-doc/utils/ctx.js b/blocks/canvas/nx-editor-doc/utils/ctx.js new file mode 100644 index 000000000..975da88e4 --- /dev/null +++ b/blocks/canvas/nx-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/nx-editor-doc/utils/load-editor-doc.js b/blocks/canvas/nx-editor-doc/utils/load-editor-doc.js new file mode 100644 index 000000000..fd7a0a67f --- /dev/null +++ b/blocks/canvas/nx-editor-doc/utils/load-editor-doc.js @@ -0,0 +1,20 @@ +import { checkDoc } from './source.js'; +import { getNx } from '../../../shared/nxutils.js'; + +export async function resolveEditorDocSession(sourceUrl) { + const { loadIms } = await import(`${getNx()}/utils/ims.js`); + const ims = await loadIms(); + 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/nx-editor-doc/utils/quick-edit-host.js b/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js new file mode 100644 index 000000000..82379d9b0 --- /dev/null +++ b/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js @@ -0,0 +1,28 @@ +import { createControllerOnMessage } from '../../nx-editor-wysiwyg/quick-edit-controller.js'; +import { getNx } from '../../../shared/nxutils.js'; +import { updateDocument, updateCursors } from '../../editor-utils/document.js'; +import { fetchWysiwygCookie } from '../../editor-utils/preview.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/nx-editor-doc/utils/shadow-mount.js b/blocks/canvas/nx-editor-doc/utils/shadow-mount.js new file mode 100644 index 000000000..61295617e --- /dev/null +++ b/blocks/canvas/nx-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 = '.nx-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/nx-editor-doc/utils/source.js b/blocks/canvas/nx-editor-doc/utils/source.js new file mode 100644 index 000000000..8c377c8ad --- /dev/null +++ b/blocks/canvas/nx-editor-doc/utils/source.js @@ -0,0 +1,21 @@ +import { DA_ADMIN } from '../../../shared/nxutils.js'; +import { daFetch } from '../../../shared/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/nx-editor-doc/utils/teardown.js b/blocks/canvas/nx-editor-doc/utils/teardown.js new file mode 100644 index 000000000..a066b17ca --- /dev/null +++ b/blocks/canvas/nx-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/nx-editor-split/nx-editor-split.css b/blocks/canvas/nx-editor-split/nx-editor-split.css new file mode 100644 index 000000000..f21bf4b97 --- /dev/null +++ b/blocks/canvas/nx-editor-split/nx-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 nx-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 nx-editor-doc { + flex: 1 1 0; + min-width: 0; + width: auto; + max-width: none; + max-height: none; +} diff --git a/blocks/canvas/nx-editor-split/nx-editor-split.js b/blocks/canvas/nx-editor-split/nx-editor-split.js new file mode 100644 index 000000000..cf44188e1 --- /dev/null +++ b/blocks/canvas/nx-editor-split/nx-editor-split.js @@ -0,0 +1,119 @@ +import { loadStyle } from '../../shared/nxutils.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('nx-editor-doc'); + const wyg = mountRoot.querySelector('nx-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/nx-editor-wysiwyg/nx-editor-wysiwyg.css b/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.css new file mode 100644 index 000000000..f09d0b7e3 --- /dev/null +++ b/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.css @@ -0,0 +1,37 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.nx-editor-wysiwyg-surface { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; +} + +.nx-editor-wysiwyg-surface[hidden] { + display: none !important; +} + +.nx-editor-wysiwyg-iframe { + flex: 1; + width: 100%; + min-height: 0; + border: 0; +} + +.nx-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/nx-editor-wysiwyg/nx-editor-wysiwyg.js b/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js new file mode 100644 index 000000000..0465d4fb7 --- /dev/null +++ b/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js @@ -0,0 +1,233 @@ +import { LitElement, html } from 'da-lit'; +import { loadStyle } from '../../shared/nxutils.js'; +import { getPreviewOrigin, fetchWysiwygCookie } from '../editor-utils/preview.js'; +import { initIms as loadIms } from '../../shared/utils.js'; +import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.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('[nx-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('[nx-editor-wysiwyg] Preview cookies failed, proceeding without cookies', e); + }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[nx-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 NxEditorWysiwyg 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('[nx-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('nx-editor-wysiwyg', NxEditorWysiwyg); diff --git a/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js b/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js new file mode 100644 index 000000000..16b5e6988 --- /dev/null +++ b/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js @@ -0,0 +1,30 @@ +import { updateDocument } from '../editor-utils/document.js'; +import { updateState, getEditor } from '../editor-utils/state.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/nx-editor-wysiwyg/utils/blocks.js b/blocks/canvas/nx-editor-wysiwyg/utils/blocks.js new file mode 100644 index 000000000..2ebddfa0f --- /dev/null +++ b/blocks/canvas/nx-editor-wysiwyg/utils/blocks.js @@ -0,0 +1,37 @@ +function getTableBlockName(tableNode) { + const firstRow = tableNode.firstChild; + if (!firstRow) return ''; + const firstCell = firstRow.firstChild; + if (!firstCell) return ''; + const raw = firstCell.textContent?.trim() ?? ''; + const match = raw.match(/^([a-zA-Z0-9_\s-]+)(?:\s*\([^)]*\))?$/); + return match ? match[1].trim().toLowerCase() : raw.toLowerCase(); +} + +export function getBlockPositions(view) { + if (!view?.state?.doc) return []; + const positions = []; + const { doc } = view.state; + doc.descendants((node, pos) => { + if (node.type.name === 'table') { + const blockName = getTableBlockName(node); + if (blockName === 'metadata') return; + positions.push(pos); + } + }); + return positions; +} + +export function getActiveBlockFlatIndex(view) { + if (!view?.state) return -1; + const { state } = view; + const cursorPos = state.selection.from; + const positions = getBlockPositions(view); + for (let i = 0; i < positions.length; i += 1) { + const start = positions[i]; + const node = state.doc.resolve(start).nodeAfter; + if (!node) continue; // eslint-disable-line no-continue + if (cursorPos >= start && cursorPos < start + node.nodeSize) return i; + } + return -1; +} diff --git a/blocks/canvas/nx-editor-wysiwyg/utils/handlers.js b/blocks/canvas/nx-editor-wysiwyg/utils/handlers.js new file mode 100644 index 000000000..4c6030ee4 --- /dev/null +++ b/blocks/canvas/nx-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/document.js'; +import { getActiveBlockFlatIndex } from './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 blockFlatIndex = getActiveBlockFlatIndex(view); + if (blockFlatIndex !== ctx.lastBlockFlatIndex) { + ctx.lastBlockFlatIndex = blockFlatIndex; + editorSelectChange.emit({ blockFlatIndex, 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/nx-editor-wysiwyg/utils/image.js b/blocks/canvas/nx-editor-wysiwyg/utils/image.js new file mode 100644 index 000000000..d0529fe9b --- /dev/null +++ b/blocks/canvas/nx-editor-wysiwyg/utils/image.js @@ -0,0 +1,122 @@ +import { DA_ADMIN, DA_CONTENT } from '../../../shared/nxutils.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/nx-page-outline/nx-page-outline.css b/blocks/canvas/nx-page-outline/nx-page-outline.css new file mode 100644 index 000000000..d30eef1de --- /dev/null +++ b/blocks/canvas/nx-page-outline/nx-page-outline.css @@ -0,0 +1,118 @@ +:host { + display: block; + height: 100%; + min-height: 0; + font-family: var(--s2-font-family); +} + +.nx-page-outline { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + margin: 0; +} + + +.nx-page-outline-list-wrap { + flex: 1; + overflow-y: auto; + padding: var(--s2-spacing-75) 0; +} + +.nx-page-outline-list { + list-style: none; + margin: 0; + padding: 0; +} + +.nx-page-outline-section { + margin: 0; + padding: 0; + border-bottom: 1px solid var(--s2-gray-200); + + &:last-child { + border-bottom: none; + } +} + +.nx-page-outline-section-header { + background: var(--s2-gray-75); +} + +.nx-page-outline-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); +} + +.nx-page-outline-block-list { + list-style: none; + margin: 0; + padding: 0; +} + +.nx-page-outline-block { + display: block; + padding: var(--s2-spacing-75) var(--s2-spacing-100); + padding-inline-start: var(--s2-spacing-300); + cursor: pointer; + 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; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--s2-gray-75); + } + + &:focus-visible { + background: var(--s2-gray-75); + } + + &[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); +} + +.nx-page-outline-block-empty { + cursor: default; +} + +.nx-page-outline-empty-label { + font-style: italic; + color: var(--s2-gray-600); + font-weight: 400; +} + +.nx-page-outline-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/nx-page-outline/nx-page-outline.js b/blocks/canvas/nx-page-outline/nx-page-outline.js new file mode 100644 index 000000000..cb316fdee --- /dev/null +++ b/blocks/canvas/nx-page-outline/nx-page-outline.js @@ -0,0 +1,152 @@ +import { LitElement, html } from 'da-lit'; +import { loadStyle, HashController } from '../../shared/nxutils.js'; +import { editorHtmlChange, editorSelectChange } from '../editor-utils/document.js'; + +const style = await loadStyle(import.meta.url); + +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 || name === 'default-content-wrapper' || name === 'metadata') return; + blocks.push({ name, blockFlatIndex: flatIndex }); + flatIndex += 1; + }); + return { sectionIndex, blocks }; + }); +} + +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 NxPageOutline extends LitElement { + static properties = { + _sections: { state: true }, + _selectedBlockFlatIndex: { 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._selectedBlockFlatIndex = undefined; + } + }); + this._unsubscribeSelect = editorSelectChange + .subscribe(({ blockFlatIndex, source }) => { + if (source === 'outline') return; + this._selectedBlockFlatIndex = blockFlatIndex; + }); + } + + 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._selectedBlockFlatIndex = undefined; + } + this._prevSelectedPath = sp; + } + + _select(blockFlatIndex) { + this._selectedBlockFlatIndex = blockFlatIndex; + editorSelectChange.emit({ blockFlatIndex, source: 'outline' }); + } + + // Arrow function so `this` is correct when used as an event listener in the template. + _onTreeKeydown = (e) => { + const items = Array.from(this.shadowRoot.querySelectorAll('[role="treeitem"]')); + if (!items.length) return; + const idx = items.indexOf(this.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(); + } + }; + + _renderSection(sec, isFirstSection) { + return html` +
  • +
    + +
    +
      + ${sec.blocks.length === 0 + ? html`
    • + No blocks +
    • ` + : sec.blocks.map(({ name, blockFlatIndex }, blockIdx) => html` +
    • this._select(blockFlatIndex)}>${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('nx-page-outline', NxPageOutline); diff --git a/blocks/canvas/nx-panel-extensions/aem-assets.js b/blocks/canvas/nx-panel-extensions/aem-assets.js new file mode 100644 index 000000000..1f21712e6 --- /dev/null +++ b/blocks/canvas/nx-panel-extensions/aem-assets.js @@ -0,0 +1,171 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { DOMParser as PMDOMParser } from 'da-y-wrapper'; +import { getNx, fetchDaConfigs, getFirstSheet } from '../../shared/nxutils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.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/nx-panel-extensions/helpers.js b/blocks/canvas/nx-panel-extensions/helpers.js new file mode 100644 index 000000000..82e8c3071 --- /dev/null +++ b/blocks/canvas/nx-panel-extensions/helpers.js @@ -0,0 +1,463 @@ +/* eslint-disable import/no-unresolved -- importmap */ +import { DOMParser as PMDOMParser, DOMSerializer, Slice, TextSelection } from 'da-y-wrapper'; +import { HLX_ADMIN, hashChange } from '../../shared/nxutils.js'; +import { daFetch } from '../../shared/utils.js'; +import { fetchDaConfigs, getFirstSheet } from '../../shared/nxutils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.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' : ''}`); + 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 (!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); + 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); + 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('../nx-page-outline/nx-page-outline.js'); + return document.createElement('nx-page-outline'); + }, + }; +} + +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('./nx-panel-extensions.js'); + const el = document.createElement('nx-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(), + ...library.map((ext) => extensionToPanelView(ext, 'Library')), + ...thirdParty.map((ext) => extensionToPanelView(ext, 'Extensions')), + ]; +} diff --git a/blocks/canvas/nx-panel-extensions/iframe-protocol.js b/blocks/canvas/nx-panel-extensions/iframe-protocol.js new file mode 100644 index 000000000..43fca5be3 --- /dev/null +++ b/blocks/canvas/nx-panel-extensions/iframe-protocol.js @@ -0,0 +1,90 @@ +import { insertText, insertHTML, getEditorSelection } from './helpers.js'; +import { getNx } from '../../shared/nxutils.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/nx-panel-extensions/nx-panel-extensions.css b/blocks/canvas/nx-panel-extensions/nx-panel-extensions.css new file mode 100644 index 000000000..25871011d --- /dev/null +++ b/blocks/canvas/nx-panel-extensions/nx-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/nx-panel-extensions/nx-panel-extensions.js b/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js new file mode 100644 index 000000000..ecc7c0a0a --- /dev/null +++ b/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js @@ -0,0 +1,57 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle, HashController } from '../../shared/nxutils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; +import './nx-panel-library.js'; + +const style = await loadStyle(import.meta.url); + +class NxPanelExtension 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('nx-panel-extension', NxPanelExtension); diff --git a/blocks/canvas/nx-panel-extensions/nx-panel-library.css b/blocks/canvas/nx-panel-extensions/nx-panel-library.css new file mode 100644 index 000000000..dbbb1fdc4 --- /dev/null +++ b/blocks/canvas/nx-panel-extensions/nx-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/nx-panel-extensions/nx-panel-library.js b/blocks/canvas/nx-panel-extensions/nx-panel-library.js new file mode 100644 index 000000000..1fc3e7742 --- /dev/null +++ b/blocks/canvas/nx-panel-extensions/nx-panel-library.js @@ -0,0 +1,267 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle, HashController } from '../../shared/nxutils.js'; +import { + fetchBlocks, + fetchItems, + insertBlock, + insertText, + insertTemplate, + getPreviewStatus, + getItemPreviewUrl, +} from './helpers.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.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 NxPanelLibrary 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 ? html` + ` : nothing} + +
      +
      + ${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` +
      + ${this._items.map((item) => html` +
    • + +
      + + +
      +
    • + `)} +
    + `; + } + + _renderKeyValueItems(label) { + if (this._items === undefined) return html`
    Loading…
    `; + if (!this._items.length) return html`
    No ${label} found.
    `; + return html` +
      + ${this._items.map((item) => 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` + this._closePreview()}> +
    +

    ${name} preview

    + +
    +
    + ${error ? html`

    ${error}

    ` : nothing} + +
    +
    + `; + } + + 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('nx-panel-library', NxPanelLibrary); diff --git a/blocks/canvas/nx-panel-header/nx-panel-header.css b/blocks/canvas/nx-panel-header/nx-panel-header.css new file mode 100644 index 000000000..fd1640ce3 --- /dev/null +++ b/blocks/canvas/nx-panel-header/nx-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/nx-panel-header/nx-panel-header.js b/blocks/canvas/nx-panel-header/nx-panel-header.js new file mode 100644 index 000000000..27582e4da --- /dev/null +++ b/blocks/canvas/nx-panel-header/nx-panel-header.js @@ -0,0 +1,31 @@ +import { loadStyle } from '../../shared/nxutils.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/inventory/browse-api.js b/blocks/inventory/browse-api.js new file mode 100644 index 000000000..6b5fca116 --- /dev/null +++ b/blocks/inventory/browse-api.js @@ -0,0 +1,29 @@ +import { DA_ADMIN } from '../shared/nxutils.js'; +import { daFetch } from '../shared/utils.js'; + +/** + * Folder listing for the given fullpath. + * @param {string} fullpath + * @returns {Promise} + */ +export async function listFolder(fullpath) { + let response; + try { + response = await daFetch(`${DA_ADMIN}/list${fullpath}`); + } catch (err) { + const message = err instanceof Error ? err.message : 'List request failed'; + return { error: message, 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 }; + } +} diff --git a/blocks/inventory/inventory.css b/blocks/inventory/inventory.css new file mode 100644 index 000000000..20d30f044 --- /dev/null +++ b/blocks/inventory/inventory.css @@ -0,0 +1,129 @@ +:host { + --browse-sheet-max-width: 1024px; + + /* Fill the .browse wrapper; max-height none drops a fixed sheet cap */ + display: flex; + flex-direction: column; + box-sizing: border-box; + flex: 1 1 auto; + height: 100%; + min-height: 0; + max-height: none; + 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 { + display: flex; + flex-direction: column; + flex: 1 1 auto; + flex-grow: 1; + 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 { + flex-shrink: 0; + box-sizing: border-box; + width: 100%; + max-width: var(--browse-sheet-max-width); + margin: 0 auto var(--s2-spacing-300); + } + + .browse-title-bar { + display: flex; + align-items: center; + gap: var(--s2-spacing-100); + box-sizing: border-box; + min-width: 0; + height: var(--s2-spacing-500); + 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 000000000..c7cb45a2d --- /dev/null +++ b/blocks/inventory/inventory.js @@ -0,0 +1,210 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { + getNx, loadStyle, hashChange, getPanelStore, openPanel, +} from '../shared/nxutils.js'; +import { listFolder } from './browse-api.js'; +import { + contextToPathContext, + entryTypeFromExtension, + isFolder, + RESOURCE_TYPE, +} from './utils.js'; + +await import(`${getNx()}/blocks/shared/breadcrumb/breadcrumb.js`); +import './list/list.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 }, + }; + + 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(); + } + + _onBrowseActivate(event) { + const { pathKey, item } = event.detail || {}; + if (entryTypeFromExtension(item.ext) === RESOURCE_TYPE.document) { + const url = new URL(window.location.href); + url.pathname = '/canvas'; + url.hash = `#/${item.path.slice(1, -(item.ext.length + 1))}`; + window.location.assign(url.href); + return; + } + if (entryTypeFromExtension(item.ext) === RESOURCE_TYPE.sheet) { + const url = new URL(window.location.href); + url.pathname = '/sheet'; + url.search = ''; + url.hash = `#/${item.path.slice(1, -(item.ext.length + 1))}`; + window.open(url.href, '_blank', 'noopener,noreferrer'); + return; + } + if (item && isFolder(item)) { + window.location.hash = `#/${pathKey}`; + } + } + + 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) ?? ''; + + if (!this._listError && this._items === undefined) { + return bar; + } + + const header = html` +
    +
    +

    ${title}

    +
    + +
    + `; + + if (this._listError) { + return html` + ${bar} + ${header} + + `; + } + + const currentPathKey = ctx.pathSegments.join('/'); + + return html` + ${bar} + ${header} + + `; + } +} + +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(); + }); + + 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 000000000..94818da6c --- /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 000000000..75f21e066 --- /dev/null +++ b/blocks/inventory/list/list.css @@ -0,0 +1,224 @@ +:host { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; + 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); + + .scroll { + height: 100%; + display: flex; + flex-direction: column; + flex-grow: 1; + min-height: 0; + min-width: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + } + + .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; + } + + & .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); + } +} diff --git a/blocks/inventory/list/list.js b/blocks/inventory/list/list.js new file mode 100644 index 000000000..2f9f73a2d --- /dev/null +++ b/blocks/inventory/list/list.js @@ -0,0 +1,198 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle } from '../../shared/nxutils.js'; +import { formatColumnLastModified } from './format.js'; +import { + getIconByExtension, + isFolder, + itemRowPathKey, + ICON_URLS, +} from '../utils.js'; + +const styles = await loadStyle(import.meta.url); + +/** `''` 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' }, + _selectedKeys: { state: true }, + }; + + willUpdate(changedProperties) { + if (changedProperties.has('currentPathKey')) { + this._selectedKeys = []; + this._emitSelectionChange(); + } + } + + updated() { + 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; + } + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + } + + _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, + }, + bubbles: true, + composed: true, + }), + ); + } + + _emitSelectionChange() { + this.dispatchEvent( + new CustomEvent('nx-browse-selection-change', { + detail: { selectedKeys: [...(this._selectedKeys ?? [])] }, + 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; + 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` +
    + + + + + + + + + + + ${items.map((item) => { + const key = itemRowPathKey(this.currentPathKey, item); + const selected = this._isRowSelected(key); + const folder = isFolder(item); + const modified = folder + ? { label: '' } + : formatColumnLastModified(item.lastModified); + const rowKind = folder ? 'row-dir' : 'row-file'; + return html` + this._onRowActivate(event, item)} + > + + + + + + `; + })} + +
    + + TypeNameLast modified
    event.stopPropagation()}> + + ${this._renderIcon(getIconByExtension(item?.ext))} + ${item.name} + + ${browseCellText(modified.label)} +
    +
    + `; + } +} + +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 000000000..5b976d922 --- /dev/null +++ b/blocks/inventory/overrides.css @@ -0,0 +1,24 @@ +/* Fixed boundary in the main content area */ +main:has(nx-browse) { + position: relative; + height: 100%; + overflow: hidden; + display: grid; + grid-template-rows: 1fr; +} + +/* Pin the browse block wrapper to main’s box */ +main:has(nx-browse) .browse { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +/* 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 000000000..5088eac67 --- /dev/null +++ b/blocks/inventory/utils.js @@ -0,0 +1,86 @@ + +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/nxutils.js b/blocks/shared/nxutils.js new file mode 100644 index 000000000..ec8b2c9d0 --- /dev/null +++ b/blocks/shared/nxutils.js @@ -0,0 +1,27 @@ +import { getNx } from '../../scripts/utils.js'; + +const { + DA_ADMIN, DA_COLLAB, DA_CONTENT, DA_ETC, DA_PREVIEW, + HLX_ADMIN, AEM_API, ALLOWED_TOKEN, + hashChange, loadStyle, loadPageStyle, HashController, getEnv, +} = await import(`${getNx()}/utils/utils.js`); + +const { openPanel, getPanelStore, closePanel } = await import(`${getNx()}/utils/panel.js`); +const { loadHrefSvg, ICONS_BASE } = await import(`${getNx()}/utils/svg.js`); +const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`); +const { + buildAemPathFromHashState, + formatAemPreviewPublishError, + runAemPreviewOrPublish, +} = await import(`${getNx()}/utils/aem-preview-publish.js`); + +export { + getNx, + DA_ADMIN, DA_COLLAB, DA_CONTENT, DA_ETC, DA_PREVIEW, + HLX_ADMIN, AEM_API, ALLOWED_TOKEN, + hashChange, loadStyle, loadPageStyle, HashController, getEnv, + openPanel, getPanelStore, closePanel, + loadHrefSvg, ICONS_BASE, + fetchDaConfigs, getFirstSheet, + buildAemPathFromHashState, formatAemPreviewPublishError, runAemPreviewOrPublish, +}; From 2b6a6a12c02be08ba76c81d265e77a00cf156937 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Thu, 7 May 2026 11:42:06 +0200 Subject: [PATCH 02/19] move chat and tool panel --- blocks/canvas/canvas.js | 14 +- blocks/ew-chat/api.js | 13 + blocks/ew-chat/chat-controller.js | 271 +++++++++++++++ blocks/ew-chat/chat.css | 416 ++++++++++++++++++++++++ blocks/ew-chat/chat.js | 282 ++++++++++++++++ blocks/ew-chat/constants.js | 71 ++++ blocks/ew-chat/persistence.js | 85 +++++ blocks/ew-chat/pills/pills.css | 62 ++++ blocks/ew-chat/pills/pills.js | 49 +++ blocks/ew-chat/prompts/prompts.css | 147 +++++++++ blocks/ew-chat/prompts/prompts.js | 124 +++++++ blocks/ew-chat/renderers.js | 119 +++++++ blocks/ew-chat/utils.js | 81 +++++ blocks/ew-chat/welcome/welcome.css | 80 +++++ blocks/ew-chat/welcome/welcome.js | 50 +++ blocks/ew-tool-panel/tool-panel.css | 133 ++++++++ blocks/ew-tool-panel/tool-panel.js | 207 ++++++++++++ blocks/inventory/inventory.js | 4 +- img/icons/s2-icon-add-20-n.svg | 3 + img/icons/s2-icon-aichat-20-n.svg | 5 + img/icons/s2-icon-arrowupsend-20-n.svg | 3 + img/icons/s2-icon-checkmark-20-n.svg | 3 + img/icons/s2-icon-chevronup-20-n.svg | 3 + img/icons/s2-icon-close-20-n.svg | 3 + img/icons/s2-icon-openin-20-n.svg | 6 + img/icons/s2-icon-paste-20-n.svg | 4 + img/icons/s2-icon-removecircle-20-n.svg | 4 + img/icons/s2-icon-search-20-n.svg | 3 + img/icons/s2-icon-splitleft-20-n.svg | 3 + img/icons/s2-icon-splitright-20-n.svg | 3 + img/icons/s2-icon-stop-20-n.svg | 3 + 31 files changed, 2245 insertions(+), 9 deletions(-) create mode 100644 blocks/ew-chat/api.js create mode 100644 blocks/ew-chat/chat-controller.js create mode 100644 blocks/ew-chat/chat.css create mode 100644 blocks/ew-chat/chat.js create mode 100644 blocks/ew-chat/constants.js create mode 100644 blocks/ew-chat/persistence.js create mode 100644 blocks/ew-chat/pills/pills.css create mode 100644 blocks/ew-chat/pills/pills.js create mode 100644 blocks/ew-chat/prompts/prompts.css create mode 100644 blocks/ew-chat/prompts/prompts.js create mode 100644 blocks/ew-chat/renderers.js create mode 100644 blocks/ew-chat/utils.js create mode 100644 blocks/ew-chat/welcome/welcome.css create mode 100644 blocks/ew-chat/welcome/welcome.js create mode 100644 blocks/ew-tool-panel/tool-panel.css create mode 100644 blocks/ew-tool-panel/tool-panel.js create mode 100644 img/icons/s2-icon-add-20-n.svg create mode 100644 img/icons/s2-icon-aichat-20-n.svg create mode 100644 img/icons/s2-icon-arrowupsend-20-n.svg create mode 100644 img/icons/s2-icon-checkmark-20-n.svg create mode 100644 img/icons/s2-icon-chevronup-20-n.svg create mode 100644 img/icons/s2-icon-close-20-n.svg create mode 100644 img/icons/s2-icon-openin-20-n.svg create mode 100644 img/icons/s2-icon-paste-20-n.svg create mode 100644 img/icons/s2-icon-removecircle-20-n.svg create mode 100644 img/icons/s2-icon-search-20-n.svg create mode 100644 img/icons/s2-icon-splitleft-20-n.svg create mode 100644 img/icons/s2-icon-splitright-20-n.svg create mode 100644 img/icons/s2-icon-stop-20-n.svg diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js index 387514def..6b674721e 100644 --- a/blocks/canvas/canvas.js +++ b/blocks/canvas/canvas.js @@ -1,4 +1,4 @@ -import { getNx, loadStyle, hashChange, getPanelStore, openPanel } from '../shared/nxutils.js'; +import { loadStyle, hashChange, getPanelStore, openPanel } from '../shared/nxutils.js'; import './nx-canvas-header/nx-canvas-header.js'; import './nx-editor-doc/nx-editor-doc.js'; import './nx-editor-wysiwyg/nx-editor-wysiwyg.js'; @@ -124,15 +124,15 @@ const CANVAS_PANELS = { before: { width: '400px', getContent: async () => { - await import(`${getNx()}/blocks/chat/chat.js`); - return document.createElement('nx-chat'); + await import('../ew-chat/chat.js'); + return document.createElement('ew-chat'); }, }, after: { width: '400px', getContent: async () => { - await import(`${getNx()}/blocks/tool-panel/tool-panel.js`); - return document.createElement('nx-tool-panel'); + await import('../ew-tool-panel/tool-panel.js'); + return document.createElement('ew-tool-panel'); }, }, }; @@ -149,7 +149,7 @@ async function openCanvasPanel(position, { preferredViewId } = {}) { const width = store[position]?.width ?? config.width; const aside = await openPanel({ position, width, getContent: config.getContent }); if (position === 'after') { - const toolPanel = aside?.querySelector('nx-tool-panel'); + const toolPanel = aside?.querySelector('ew-tool-panel'); if (toolPanel) { await syncToolPanelViews(toolPanel, hashState()); await toolPanel.updateComplete; @@ -198,7 +198,7 @@ export default async function decorate(block) { hashChange.subscribe((state) => { syncCanvasEditorsToHash({ mountRoot, header, state }); - const toolPanel = document.querySelector('aside.panel[data-position="after"] nx-tool-panel'); + const toolPanel = document.querySelector('aside.panel[data-position="after"] ew-tool-panel'); if (toolPanel) syncToolPanelViews(toolPanel, state); }); diff --git a/blocks/ew-chat/api.js b/blocks/ew-chat/api.js new file mode 100644 index 000000000..58172c332 --- /dev/null +++ b/blocks/ew-chat/api.js @@ -0,0 +1,13 @@ +import { DA_ADMIN } from '../shared/nxutils.js'; +import { daFetch } from '../shared/utils.js'; + +export async function loadPrompts(org, site) { + try { + const resp = await daFetch(`${DA_ADMIN}/config/${org}/${site}`); + if (!resp.ok) return null; + const json = await resp.json(); + return json?.prompts?.data ?? []; + } catch { + return null; + } +} diff --git a/blocks/ew-chat/chat-controller.js b/blocks/ew-chat/chat-controller.js new file mode 100644 index 000000000..d9ce58947 --- /dev/null +++ b/blocks/ew-chat/chat-controller.js @@ -0,0 +1,271 @@ +import { initIms as loadIms } from '../shared/utils.js'; +import { AGENT_EVENT, ROLE, TOOL_STATE } from './constants.js'; +import { readStream } from './utils.js'; +import { loadMessages, saveMessages, clearMessages } from './persistence.js'; + +// ?ref=local routes to a local da-agent dev server (port 5173). +const AGENT_URL = new URLSearchParams(window.location.search).get('ref') === 'local' + ? 'http://localhost:4200/chat' + : 'https://da-agent.adobeaem.workers.dev/chat'; + +export default class ChatController { + constructor({ onUpdate, onToolDone }) { + this._onUpdate = onUpdate; + this._onToolDone = onToolDone; + } + + setContext(context) { + this._context = context; + this._room = null; + } + + _pageContextForAgent() { + const { org, site, path, view } = this._context ?? {}; + return org && site ? { org, site, path, view } : undefined; + } + + async _getRoom() { + if (this._room) return this._room; + const { userId } = await loadIms(); + const { org, site } = this._context ?? {}; + this._room = org && site && userId ? `${org}--${site}--${userId}` : 'default'; + return this._room; + } + + async loadInitialMessages() { + this._messages = []; + const room = await this._getRoom(); + const cached = await loadMessages(room); + if (!cached.length) return; + // Strip orphaned tool-calls (assistant array-content without a tool-approval-request). + // These are from sessions before the current fix — they have no matching tool-result + // and cause "Tool result is missing". Complete approval sequences are kept so users + // see what the agent approved and did in prior conversations. + this._messages = cached.filter( + (msg) => !(msg.role === ROLE.ASSISTANT && Array.isArray(msg.content) + && !msg.virtual + && !msg.content.some((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_REQUEST)), + ); + // Reconstruct tool cards from persisted approval messages so they render on reload. + this._toolCards = new Map(); + for (const msg of this._messages) { + if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { + const call = msg.content.find((p) => p.type === AGENT_EVENT.TOOL_CALL); + if (call) { + this._toolCards.set(call.toolCallId, { + toolName: call.toolName, input: call.input, state: TOOL_STATE.DONE, + }); + } + } + } + this._update(); + } + + _update() { + this._onUpdate({ + messages: this._messages, + thinking: this._thinking, + streamingText: this._streamingText, + connected: this._connected, + toolCards: this._toolCards, + }); + } + + async connect(attempt = 0) { + try { + await fetch(AGENT_URL, { method: 'HEAD', signal: AbortSignal.timeout(5000) }); + this._connected = true; + } catch { + this._connected = false; + const delay = 1000 * 2 ** attempt; + if (delay < 30000) this._retryTimeout = setTimeout(() => this.connect(attempt + 1), delay); + } finally { + this._update(); + } + } + + _done() { + this._abortController = null; + this._thinking = false; + this._streamingText = undefined; + this._update(); + } + + stop() { + this._abortController?.abort(); + this._done(); + } + + async clear() { + if (this._thinking) this.stop(); + this._messages = undefined; + this._streamingText = undefined; + this._toolCards = new Map(); + this._autoApprovedTools = new Set(); + this._update(); + const room = await this._getRoom(); + clearMessages(room); + } + + destroy() { + clearTimeout(this._retryTimeout); + this.stop(); + } + + _onToolEvent = ({ + type, toolCallId, toolName, input, output, isError, approvalId, + }) => { + const next = new Map(this._toolCards ?? []); + + if (type === AGENT_EVENT.TOOL_CALL) { + if (next.has(toolCallId)) return; // duplicate — ignore + next.set(toolCallId, { toolName, input, state: TOOL_STATE.RUNNING }); + } else if (type === AGENT_EVENT.TOOL_APPROVAL_REQUEST) { + const autoApprove = this._autoApprovedTools?.has(toolName); + // Promote to _messages now that we know approval is needed. + // Both parts go in one message — resolveApprovals() matches tool-approval-request + // to tool-call by toolCallId within the same assistant message. + const prior = next.get(toolCallId) ?? { toolName, input: {} }; + this._messages = [ + ...this._messages, + { + role: ROLE.ASSISTANT, + content: [ + { + type: AGENT_EVENT.TOOL_CALL, + toolCallId, + toolName: prior.toolName, + input: prior.input, + }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId, toolCallId }, + ], + }, + ]; + const state = autoApprove ? TOOL_STATE.APPROVED : TOOL_STATE.APPROVAL_REQUESTED; + next.set(toolCallId, { ...prior, state, approvalId }); + this._toolCards = next; + this._update(); + if (autoApprove) queueMicrotask(() => this.approveToolCall(toolCallId, true)); + return; + } else { + const prior = next.get(toolCallId) ?? { toolName, input: {} }; + const state = isError ? TOOL_STATE.ERROR : TOOL_STATE.DONE; + next.set(toolCallId, { ...prior, state, output }); + if (state === TOOL_STATE.DONE) { + // Add a virtual message so the tool renders in the conversation at the right + // position and persists across refreshes, without being sent back to the agent. + this._messages = [ + ...this._messages, + { + role: ROLE.ASSISTANT, + virtual: true, + content: [{ type: AGENT_EVENT.TOOL_CALL, toolCallId, toolName: prior.toolName }], + }, + ]; + this._onToolDone?.(); + } + } + + this._toolCards = next; + this._update(); + }; + + approveToolCall = async (toolCallId, approved, always = false) => { + const card = this._toolCards.get(toolCallId); + if (!card?.approvalId) return; + + if (always) { + this._autoApprovedTools ??= new Set(); + this._autoApprovedTools.add(card.toolName); + } + + const next = new Map(this._toolCards ?? []); + next.set(toolCallId, { ...card, state: approved ? TOOL_STATE.APPROVED : TOOL_STATE.REJECTED }); + this._toolCards = next; + + this._messages = [ + ...this._messages, + { + role: ROLE.TOOL, + content: [{ + type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId: card.approvalId, approved, + }], + }, + ]; + this._thinking = approved; + this._update(); + + if (approved) { + try { + await this._stream(this._pageContextForAgent()); + } catch (err) { + if (err.name !== 'AbortError') { + this._messages = [...this._messages, { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }]; + } + } finally { + this._done(); + } + } else { + this._done(); + } + }; + + async _stream(pageContext) { + const [{ accessToken }, room] = await Promise.all([loadIms(), this._getRoom()]); + this._abortController = new AbortController(); + + const resp = await fetch(AGENT_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + messages: this._messages.filter((msg) => !msg.virtual), + pageContext, + context: this._pendingContext ?? [], + imsToken: accessToken?.token ?? null, + room, + }), + signal: this._abortController.signal, + }); + + this._pendingContext = []; + + if (!resp.ok) { + throw new Error(`Agent responded with ${resp.status}: ${await resp.text()}`); + } + + await readStream(resp.body, { + onDelta: (next) => { this._streamingText = next; this._update(); }, + onText: (text) => { + this._messages = [...this._messages, { role: ROLE.ASSISTANT, content: text }]; + this._streamingText = ''; + this._update(); + saveMessages(room, this._messages); + }, + onTool: this._onToolEvent, + }); + } + + async sendMessage(message, context = []) { + if (this._thinking || !this._connected) return; + + this._pendingContext = context; + this._messages = [...(this._messages ?? []), { role: ROLE.USER, content: message }]; + this._thinking = true; + this._update(); + + this._toolCards = new Map(); + + try { + await this._stream(this._pageContextForAgent()); + } catch (err) { + if (err.name !== 'AbortError') { + this._messages = [ + ...this._messages, + { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }, + ]; + } + } finally { + this._done(); + } + } +} diff --git a/blocks/ew-chat/chat.css b/blocks/ew-chat/chat.css new file mode 100644 index 000000000..82cba1ea0 --- /dev/null +++ b/blocks/ew-chat/chat.css @@ -0,0 +1,416 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: var(--s2-spacing-300); + padding: var(--s2-spacing-200) 0; + font-family: var(--s2-font-family); + color: var(--s2-gray-800); + font-weight: var(--s2-body-font-weight); + box-sizing: border-box; + align-items: center; + + [hidden] { + display: none; + } + + kbd { + font-family: inherit; + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-700); + padding: var(--s2-spacing-75); + border-radius: var(--s2-corner-radius-300); + background-color: var(--s2-gray-300); + } + + button { + border: none; + background: none; + padding: 0; + cursor: pointer; + font-family: inherit; + + &.action-btn { + background-color: var(--s2-gray-800); + color: var(--s2-gray-25); + + kbd { + background-color: var(--s2-gray-700); + color: var(--s2-gray-25); + } + + img { + filter: invert(1); + } + } + + &.secondary-btn { + background-color: var(--s2-gray-100); + color: var(--s2-gray-800); + + kbd { + background-color: var(--s2-gray-300); + color: var(--s2-gray-800); + } + + &:hover { + background-color: var(--s2-gray-200); + } + } + } + + textarea { + resize: none; + border: none; + outline: none; + } +} + +.chat-actions img { + width: 16px; + height: 16px; +} + +.chat-header { + display: flex; + align-items: center; + width: 100%; + padding: 0 var(--s2-spacing-300); + box-sizing: border-box; + justify-content: flex-end; + gap: var(--s2-spacing-300); + + .chat-header-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s2-spacing-75); + min-width: 24px; + min-height: 24px; + border: none; + border-radius: var(--s2-corner-radius-400); + background: transparent; + color: var(--s2-gray-800); + cursor: pointer; + font-family: inherit; + font-size: var(--s2-body-size-s); + + &.clear-btn { + padding: 0 var(--s2-spacing-100); + } + + img { + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + } + + &:hover { + background-color: var(--s2-gray-75); + } + + &[hidden] { + display: none; + } + } +} + +.chat-messages-container, +.chat-form { + max-width: 720px; + box-sizing: border-box; +} + +.prompts-popover { + max-width: 720px; + box-sizing: border-box; + top: auto; + padding: 0; +} + +.chat-scroll-container { + overflow-y: auto; + flex: 1 1 0; + width: 100%; +} + +.chat-messages-container { + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); + padding: 0 var(--s2-spacing-300); + margin: 0 auto; + height: 100%; +} + +.tool-card { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); + + summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: var(--s2-spacing-75); + + &::before { + content: "›"; + display: inline-block; + transition: transform 0.15s; + } + } + + &[open] summary::before { + transform: rotate(90deg); + } + + .tool-card-status { + text-transform: uppercase; + font-size: var(--s2-body-size-xs); + } + + &.tool-card-error .tool-card-status, + &.tool-card-rejected .tool-card-status { + color: var(--s2-red-700); + } + + .tool-card-detail { + display: block; + padding-left: var(--s2-spacing-200); + color: var(--s2-gray-500); + overflow-wrap: break-word; + } +} + +.message { + padding: 6px var(--s2-spacing-200); + border-radius: 12px; + overflow-wrap: break-word; + font-size: var(--s2-body-size-s); + + &.message-user { + max-width: 80%; + align-self: flex-end; + background-color: var(--s2-gray-75); + } + + &.message-assistant { + padding-right: var(--s2-spacing-75); + padding-left: var(--s2-spacing-100); + display: flex; + flex-direction: column; + gap: var(--s2-spacing-75); + } + + .message-content p { + margin: 0; + } + + .message-action-copy { + align-self: flex-end; + visibility: hidden; + box-sizing: border-box; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &::before { + content: ""; + width: 16px; + height: 16px; + flex-shrink: 0; + display: block; + background-color: var(--s2-gray-500); + mask: url("/img/icons/s2-icon-paste-20-n.svg") center / contain + no-repeat; + } + + &:focus::before { + mask: url("/img/icons/s2-icon-checkmark-20-n.svg") center / contain + no-repeat; + } + } + + &:hover .message-action-copy { + visibility: visible; + } +} + +.chat-form-wrap { + max-width: 720px; + width: calc(100% - 2 * var(--s2-spacing-200)); + box-sizing: border-box; + position: relative; +} + +.chat-form { + display: flex; + flex-direction: column; + border: 1px solid var(--s2-gray-300); + border-radius: var(--s2-corner-radius-500); + overflow: hidden; + padding: var(--s2-spacing-75) var(--s2-spacing-100) var(--s2-spacing-100) + var(--s2-spacing-100); + gap: var(--s2-spacing-75); + margin: 0 auto; +} + +.chat-actions { + display: flex; + justify-content: flex-end; + gap: var(--s2-spacing-100); + + button { + width: 24px; + height: 24px; + border-radius: var(--s2-corner-radius-800); + justify-content: center; + align-items: center; + display: none; + } + + .chat-add { + display: flex; + margin-inline-end: auto; + border-radius: var(--s2-corner-radius-400); + + &[data-active], + &:hover { + background-color: var(--s2-gray-200); + } + + .icon-up { + display: none; + } + } + + .chat-send { + background-color: var(--s2-blue-900); + } + + .chat-stop { + background-color: var(--s2-gray-800); + } + + &.chat-thinking .chat-stop { + display: flex; + } +} + +.chat-input { + field-sizing: content; + padding: var(--s2-spacing-100) 0; + font-family: inherit; + color: var(--s2-gray-900); + min-height: 1lh; + max-height: 2.5lh; + line-height: 1.5; + font-size: var(--s2-body-size-s); + background: inherit; + + &:not(:placeholder-shown) + .chat-actions .chat-send { + display: flex; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + background: inherit; + } +} + +.chat-thinking { + padding: var(--s2-spacing-75) var(--s2-spacing-100); + font-size: var(--s2-body-size-xs); + font-weight: var(--s2-component-m-regular-font-weight); + letter-spacing: 0.02em; + + /* Sweeping highlight: gray-600 base, gray-400 band (background-clip text) */ + background: linear-gradient( + 90deg, + var(--s2-gray-600) 0%, + var(--s2-gray-600) 32%, + var(--s2-gray-400) 50%, + var(--s2-gray-600) 68%, + var(--s2-gray-600) 100% + ); + background-size: 240% 100%; + + /* stylelint-disable-next-line property-no-vendor-prefix -- Safari: gradient text clip */ + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + animation: chat-thinking-shimmer 2.2s ease-in-out infinite; +} + +.approval-actions { + position: absolute; + bottom: 10px; + left: -4px; + right: -4px; + background: var(--s2-gray-25); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-500); + box-shadow: 0 4px 16px rgb(0 0 0 / 12%); + display: flex; + flex-direction: column; + gap: var(--s2-spacing-100); + padding: var(--s2-spacing-100); + + .approval-tool-name { + font-weight: bold; + font-size: var(--s2-body-size-s); + color: var(--s2-gray-800); + } + + .approval-summary { + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-600); + overflow-wrap: break-word; + } + + .approval-buttons { + display: flex; + gap: var(--s2-spacing-100); + justify-content: flex-end; + padding-top: var(--s2-spacing-100); + } + + button { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--s2-spacing-75); + border-radius: var(--s2-corner-radius-400); + font-size: var(--s2-body-size-s); + padding: var(--s2-spacing-75) var(--s2-spacing-75) var(--s2-spacing-75) + var(--s2-spacing-100); + } +} + +:host:has(.prompts-popover[open]) .chat-add { + .icon-add { + display: none; + } + + .icon-up { + display: flex; + } +} + +@keyframes chat-thinking-shimmer { + 0% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } +} diff --git a/blocks/ew-chat/chat.js b/blocks/ew-chat/chat.js new file mode 100644 index 000000000..10a64aab6 --- /dev/null +++ b/blocks/ew-chat/chat.js @@ -0,0 +1,282 @@ +import { LitElement, html, nothing } from 'da-lit'; +import ChatController from './chat-controller.js'; +import { renderMessage, renderApprovalCard } from './renderers.js'; +import './welcome/welcome.js'; +import './prompts/prompts.js'; +import './pills/pills.js'; +import { loadStyle, hashChange, getNx } from '../shared/nxutils.js'; +import { loadPrompts } from './api.js'; +import { ADD_MENU_ITEMS, MENU_OPTIONS, ROLE, TOOL_STATE } from './constants.js'; + +await import(`${getNx()}/blocks/shared/menu/menu.js`); + +const styles = await loadStyle(import.meta.url); + +const ICON_SRCS = { + add: '/img/icons/s2-icon-add-20-n.svg', + clear: '/img/icons/s2-icon-removecircle-20-n.svg', + close: '/img/icons/s2-icon-splitleft-20-n.svg', + send: '/img/icons/s2-icon-arrowupsend-20-n.svg', + stop: '/img/icons/s2-icon-stop-20-n.svg', + up: '/img/icons/s2-icon-chevronup-20-n.svg', +}; + +const icon = (name) => html``; + +const UI_PROMPTS_GAP = 8; + +class EwChat extends LitElement { + static properties = { + messages: { type: Array }, + thinking: { type: Boolean }, + connected: { type: Boolean }, + toolCards: { type: Object }, + _prompts: { state: true }, + _attachedItems: { state: true }, + }; + + set context(value) { + this._explicitContext = true; + this._applyContext(value); + } + + _onAddToChat = ({ detail }) => this.addAttachment(detail); + + addAttachment(item) { + const current = this._attachedItems ?? []; + if (current.some((i) => i.id === item.id)) return; + this._attachedItems = [...current, item]; + } + + _applyContext(value) { + this._context = value; + this._controller?.setContext(value); + this._loadPrompts(); + this.requestUpdate(); + } + + clear() { + this._controller?.clear(); + } + + _closePanel() { + this.dispatchEvent(new CustomEvent('nx-panel-close', { bubbles: true, composed: true })); + } + + async _loadPrompts() { + const { org, site } = this._context ?? {}; + if (!org || !site) return; + const key = `${org}/${site}`; + if (this._promptsKey === key) return; + this._promptsKey = key; + const data = await loadPrompts(org, site); + if (data) this._prompts = data.filter((p) => p.title && p.prompt); + } + + async connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + + this._controller = new ChatController({ + onToolDone: () => { + this.dispatchEvent(new CustomEvent('nx-agent-change', { bubbles: true, composed: true })); + }, + onUpdate: ({ + messages, thinking, streamingText, connected, toolCards, + }) => { + this.messages = streamingText + ? [...(messages ?? []), { role: ROLE.ASSISTANT, content: streamingText, streaming: true }] + : messages; + this.thinking = thinking; + this.connected = connected; + this.toolCards = toolCards; + }, + }); + if (this._context) this._controller.setContext(this._context); + + this._unsubscribeHash = hashChange.subscribe((state) => { + if (!this._explicitContext) this._applyContext(state); + }); + + this._controller.connect().then(() => this._controller.loadInitialMessages()); + document.addEventListener('nx-add-to-chat', this._onAddToChat); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._unsubscribeHash?.(); + this._controller?.destroy(); + document.removeEventListener('keydown', this._onApprovalKeydown); + document.removeEventListener('nx-add-to-chat', this._onAddToChat); + } + + _pendingApproval() { + if (!this.toolCards) return null; + for (const [toolCallId, card] of this.toolCards) { + if (card.state === TOOL_STATE.APPROVAL_REQUESTED) return { toolCallId, ...card }; + } + return null; + } + + _onApprovalKeydown = (e) => { + const pending = this._pendingApproval(); + if (!pending) return; + if (e.key === 'Escape') { + e.preventDefault(); + this._controller.approveToolCall(pending.toolCallId, false); + } else if (e.key === 'Enter' && e.metaKey) { + e.preventDefault(); + this._controller.approveToolCall(pending.toolCallId, true, true); + } else if (e.key === 'Enter') { + e.preventDefault(); + this._controller.approveToolCall(pending.toolCallId, true); + } + }; + + updated(changed) { + if (changed.has('messages')) { + const log = this.shadowRoot.querySelector('.chat-scroll-container'); + if (log) requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; }); + } + if (changed.has('thinking') && !this.thinking && changed.get('thinking')) { + this.shadowRoot.querySelector('.chat-input')?.focus(); + } + if (changed.has('toolCards')) { + if (this._pendingApproval()) { + document.addEventListener('keydown', this._onApprovalKeydown); + } else { + document.removeEventListener('keydown', this._onApprovalKeydown); + } + } + } + + _openPrompts() { + const popover = this.shadowRoot.querySelector('.prompts-popover'); + const form = this.shadowRoot.querySelector('.chat-form'); + if (!popover || !form) return; + const { left, width, top } = form.getBoundingClientRect(); + popover.style.left = `${left}px`; + popover.style.width = `${width}px`; + popover.style.bottom = `${window.innerHeight - top + UI_PROMPTS_GAP}px`; + popover.style.height = `${Math.min(top - UI_PROMPTS_GAP, 400)}px`; + popover.addEventListener('toggle', ({ newState }) => { + if (newState === 'open') this.shadowRoot.querySelector('nx-prompts')?.focus(); + }, { once: true }); + popover.show(); + } + + _onAddClick(e) { + const popover = this.shadowRoot.querySelector('.prompts-popover'); + if (!popover?.open) return; + e.stopImmediatePropagation(); + popover.close(); + } + + _handleKeydown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this._submit(); + } + } + + _submit(e) { + e?.preventDefault(); + if (this.thinking) { + this._controller.stop(); + return; + } + const input = this.shadowRoot.querySelector('.chat-input'); + const message = input.value.trim(); + if (!message && !this._attachedItems?.length) return; + const context = this._attachedItems ?? []; + this._controller.sendMessage(message, context); + input.value = ''; + this._attachedItems = []; + } + + _sendPrompt(prompt) { + if (!prompt || this.thinking || !this.connected) return; + this.shadowRoot.querySelector('.prompts-popover')?.close(); + this._controller.sendMessage(prompt); + } + + _handleMenuSelect({ detail: { id } }) { + if (id === MENU_OPTIONS.PROMPT) this._openPrompts(); + } + + _handlePillRemove({ detail: { id } }) { + this._attachedItems = (this._attachedItems ?? []).filter((item) => item.id !== id); + } + + render() { + const { view } = this._context ?? {}; + const prompts = (this._prompts ?? []) + .filter((p) => !p.area || p.area === 'all' || p.area === view); + + return html` + + this._sendPrompt(p)} + > + +
    + + +
    +
    +
    + ${!this.messages?.length && !this.thinking + ? html` this._sendPrompt(p)} + @nx-show-prompts=${this._openPrompts} + >` + : nothing} + ${this.messages?.map((msg) => renderMessage(msg, null, this.toolCards))} + ${this.thinking && !this.messages?.at(-1)?.streaming ? html`
    Thinking...
    ` : nothing} +
    +
    +
    + ${renderApprovalCard(this._pendingApproval(), this._controller.approveToolCall)} +
    + ${this._attachedItems?.length ? html` + ` : nothing} + +
    + + + + + +
    +
    +
    + `; + } +} + +customElements.define('ew-chat', EwChat); diff --git a/blocks/ew-chat/constants.js b/blocks/ew-chat/constants.js new file mode 100644 index 000000000..26f652940 --- /dev/null +++ b/blocks/ew-chat/constants.js @@ -0,0 +1,71 @@ +const MENU_OPTIONS = { + PROMPT: 'prompt', +}; + +const ADD_MENU_ITEMS = [ + { section: 'Add' }, + { id: 'files', label: 'Files or images', icon: 'Link' }, + { id: MENU_OPTIONS.PROMPT, label: 'Prompt', icon: 'CommentText' }, + { id: 'command', label: '"/" Command', icon: 'Prompt' }, + { divider: true }, + { id: 'prompts', label: 'Manage Prompts' }, + { id: 'skills', label: 'Manage Skills' }, +]; + +const CHAT_ICONS = { + add: 'Add', clear: 'RemoveCircle', close: 'SplitLeft', send: 'ArrowUpSend', stop: 'Stop', up: 'ChevronUp', +}; + +/** + * Agent stream event types. + * Source: Vercel AI SDK v6 UIMessageStream format, as emitted by da-agent. + * TODO: move to a shared @da/agent-types package so both sides import from one place. + */ +const AGENT_EVENT = { + TEXT_DELTA: 'text-delta', + TEXT_END: 'text-end', + FINISH: 'finish', + FINISH_MESSAGE: 'finish-message', + ERROR: 'error', + // tool-input-available is the legacy alias for tool-call + TOOL_CALL: 'tool-call', + TOOL_CALL_LEGACY: 'tool-input-available', + // tool-output-available is the legacy alias for tool-result + TOOL_RESULT: 'tool-result', + TOOL_RESULT_LEGACY: 'tool-output-available', + TOOL_APPROVAL_REQUEST: 'tool-approval-request', + TOOL_APPROVAL_RESPONSE: 'tool-approval-response', +}; + +const TOOL_STATE = { + RUNNING: 'running', + APPROVAL_REQUESTED: 'approval-requested', + APPROVED: 'approved', + REJECTED: 'rejected', + DONE: 'done', + ERROR: 'error', +}; + +/** + * Input field names used in tool approval summary rendering. + * These are da-agent tool input schema field names — part of the agent-client contract. + * TODO: move to @da/agent-types once the agent team can publish one. + */ +const TOOL_INPUT = { + HUMAN_READABLE_SUMMARY: 'humanReadableSummary', + SOURCE_PATH: 'sourcePath', + DESTINATION_PATH: 'destinationPath', + PATH: 'path', + SKILL_ID: 'skillId', + NAME: 'name', +}; + +const ROLE = { + USER: 'user', + ASSISTANT: 'assistant', + TOOL: 'tool', +}; + +export { + ADD_MENU_ITEMS, AGENT_EVENT, CHAT_ICONS, MENU_OPTIONS, ROLE, TOOL_INPUT, TOOL_STATE, +}; diff --git a/blocks/ew-chat/persistence.js b/blocks/ew-chat/persistence.js new file mode 100644 index 000000000..88e517851 --- /dev/null +++ b/blocks/ew-chat/persistence.js @@ -0,0 +1,85 @@ +const DB_NAME = 'da-chat'; +const DB_VERSION = 1; +const STORE_NAME = 'conversations'; + +let dbPromise = null; + +function closeDb(resolve) { + dbPromise = null; + resolve(null); +} + +function openDb() { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve) => { + let req; + try { + req = indexedDB.open(DB_NAME, DB_VERSION); + } catch { + closeDb(resolve); + return; + } + + req.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'room' }); + } + }; + + req.onsuccess = (e) => { + const db = e.target.result; + db.onversionchange = () => db.close(); + resolve(db); + }; + + req.onerror = () => closeDb(resolve); + req.onblocked = () => { + dbPromise = null; + setTimeout(() => openDb().then(resolve), 500); + }; + }); + + return dbPromise; +} + +async function write(fn) { + const db = await openDb(); + if (!db) return; + try { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.onerror = () => { }; // best-effort, prevent bubbling to window.onerror + fn(tx.objectStore(STORE_NAME)); + } catch { + // best-effort + } +} + +export async function loadMessages(room) { + const db = await openDb(); + if (!db) return []; + + return new Promise((resolve) => { + try { + const tx = db.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).get(room); + + req.onsuccess = (e) => { + const { result } = e.target; + resolve(Array.isArray(result?.messages) ? result.messages : []); + }; + req.onerror = () => resolve([]); + } catch { + resolve([]); + } + }); +} + +export function saveMessages(room, messages) { + return write((store) => store.put({ room, messages, updatedAt: Date.now() })); +} + +export function clearMessages(room) { + return write((store) => store.delete(room)); +} diff --git a/blocks/ew-chat/pills/pills.css b/blocks/ew-chat/pills/pills.css new file mode 100644 index 000000000..efc502d25 --- /dev/null +++ b/blocks/ew-chat/pills/pills.css @@ -0,0 +1,62 @@ +:host { + button { + background: none; + border: none; + cursor: pointer; + padding: 0; + } + + ul { + margin: 0; + list-style: none; + } + + .pills-container { + --pill-row-height: calc( + var(--s2-component-s-medium-line-height) + 2 * var(--s2-spacing-75) + ); + + display: flex; + flex-wrap: wrap; + gap: var(--s2-spacing-100); + max-height: calc(2 * var(--pill-row-height) + var(--s2-spacing-100)); + overflow-y: auto; + padding: var(--s2-spacing-75) 0; + } + + .pill { + display: inline-flex; + align-items: center; + gap: var(--s2-spacing-75); + padding: var(--s2-spacing-75) var(--s2-spacing-100); + background-color: var(--s2-blue-200); + border-radius: var(--s2-corner-radius-400); + max-width: 200px; + min-width: 0; + box-sizing: border-box; + justify-content: center; + } + + .pill-icon::before { + content: ""; + display: block; + width: 12px; + height: 12px; + background-color: var(--s2-gray-800); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url("/img/icons/s2-icon-close-20-n.svg"); + } + + .pill-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; + font-size: var(--s2-body-size-xs); + color: var(--s2-blue-1000); + line-height: var(--s2-component-s-medium-line-height); + } +} diff --git a/blocks/ew-chat/pills/pills.js b/blocks/ew-chat/pills/pills.js new file mode 100644 index 000000000..26aa65338 --- /dev/null +++ b/blocks/ew-chat/pills/pills.js @@ -0,0 +1,49 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle } from '../../shared/nxutils.js'; + +const styles = await loadStyle(import.meta.url); + +class NxChatPills extends LitElement { + static properties = { + items: { type: Array }, + }; + + constructor() { + super(); + this.items = []; + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + } + + _remove(id) { + this.dispatchEvent(new CustomEvent('nx-pill-remove', { detail: { id } })); + } + + _renderPill({ id, label }) { + return html` +
  • + + ${label} +
  • + `; + } + + render() { + if (!this.items?.length) return nothing; + return html` +
      + ${this.items.map((item) => this._renderPill(item))} +
    + `; + } +} + +customElements.define('nx-chat-pills', NxChatPills); diff --git a/blocks/ew-chat/prompts/prompts.css b/blocks/ew-chat/prompts/prompts.css new file mode 100644 index 000000000..9ce225e89 --- /dev/null +++ b/blocks/ew-chat/prompts/prompts.css @@ -0,0 +1,147 @@ +:host { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-xs); + gap: var(--s2-spacing-100); + + button { + cursor: pointer; + font-family: inherit; + font-size: inherit; + } +} + +.prompts-header { + display: flex; + align-items: center; + gap: var(--s2-spacing-100); + padding: var(--s2-spacing-100) var(--s2-spacing-100) 0 var(--s2-spacing-100); + + &::before { + content: ""; + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + background-color: var(--s2-gray-600); + mask-image: url("/img/icons/s2-icon-search-20-n.svg"); + mask-size: contain; + mask-repeat: no-repeat; + } +} + +.prompts-search-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: none; + font-family: inherit; + font-size: var(--s2-body-size-xs); + color: var(--s2-gray-900); + padding: 0; + + &::placeholder { + color: var(--s2-gray-600); + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.prompts-clear { + display: none; + flex-shrink: 0; + border: none; + background: none; + padding: 0; + cursor: pointer; + + &::before { + content: ""; + display: block; + width: 16px; + height: 16px; + background-color: var(--s2-gray-600); + mask-image: url("/img/icons/s2-icon-close-20-n.svg"); + mask-size: contain; + mask-repeat: no-repeat; + } +} + +.prompts-search-input:not(:placeholder-shown) + .prompts-clear { + display: block; +} + +.prompts-list { + flex: 1 1 0; + overflow-y: auto; + padding: var(--s2-spacing-75) 0; + list-style: none; + margin: 0; + border-top: 1px solid var(--s2-gray-100); + + li { + border-top: 1px solid var(--s2-gray-100); + padding: var(--s2-spacing-75) 0; + margin: 0 var(--s2-spacing-100); + + &:first-child { + border-top: none; + } + } +} + +.prompt-item { + display: flex; + flex-direction: column; + width: 100%; + text-align: left; + border: none; + border-radius: var(--s2-corner-radius-400); + background: none; + padding: var(--s2-spacing-75) var(--s2-spacing-100); + gap: var(--s2-spacing-75); + color: var(--s2-gray-800); + + &:hover, + &:focus-visible { + background: var(--s2-gray-75); + outline: none; + } +} + +.prompt-item-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--s2-spacing-200); + line-height: var(--s2-component-xs-medium-line-height); + font-size: var(--s2-component-xs-medium-font-size); + + .prompt-item-title { + color: var(--s2-gray-700); + } + + .prompt-item-category { + color: var(--s2-gray-500); + } +} + +.prompt-item-desc { + color: var(--s2-gray-900); + font-size: var(--s2-component-s-regular-font-size); + line-height: var(--s2-component-s-regular-line-height); + text-overflow: ellipsis; +} + +.prompts-empty { + margin: 0; + padding: var(--s2-spacing-400) var(--s2-spacing-300); + color: var(--s2-gray-600); + text-align: center; +} diff --git a/blocks/ew-chat/prompts/prompts.js b/blocks/ew-chat/prompts/prompts.js new file mode 100644 index 000000000..b5eb83af6 --- /dev/null +++ b/blocks/ew-chat/prompts/prompts.js @@ -0,0 +1,124 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle, getNx } from '../../shared/nxutils.js'; + +await import(`${getNx()}/blocks/shared/picker/picker.js`); + +const styles = await loadStyle(import.meta.url); + +const ALL_CATEGORY = 'all'; + +class NxPrompts extends LitElement { + static properties = { + prompts: { attribute: false }, + _search: { state: true }, + _category: { state: true }, + }; + + _search = ''; + + _category = ALL_CATEGORY; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + } + + willUpdate(changed) { + if (changed.has('prompts')) { + const seen = new Set(); + this._categories = [ + { value: ALL_CATEGORY, label: 'All' }, + ...(this.prompts ?? []) + .map((p) => p.category) + .filter((c) => c && c !== ALL_CATEGORY && !seen.has(c) && seen.add(c)) + .map((c) => ({ value: c, label: c })), + ]; + } + } + + get _filtered() { + const search = this._search.toLowerCase(); + return (this.prompts ?? []).filter((p) => { + if (this._category !== ALL_CATEGORY && p.category !== this._category) return false; + if (!search) return true; + return p.title?.toLowerCase().includes(search) + || p.description?.toLowerCase().includes(search); + }); + } + + _onListKeydown(e) { + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; + const items = [...this.shadowRoot.querySelectorAll('.prompt-item')]; + if (!items.length) return; + e.preventDefault(); + const cur = items.indexOf(e.target); + const next = e.key === 'ArrowDown' + ? items[(cur + 1) % items.length] + : items[(cur <= 0 ? items.length : cur) - 1]; + next.focus({ preventScroll: true }); + } + + _onSearch(e) { + this._search = e.target.value; + } + + _clearSearch() { + const input = this.shadowRoot.querySelector('.prompts-search-input'); + if (input) input.value = ''; + this._search = ''; + input?.focus(); + } + + _onCategoryChange(e) { + this._category = e.detail.value; + } + + focus() { + this.shadowRoot.querySelector('.prompts-search-input')?.focus(); + } + + render() { + const total = this.prompts?.length ?? 0; + const filtered = this._filtered; + const placeholder = this._category === ALL_CATEGORY + ? `Search all ${total} prompts` + : `Search in ${this._category}`; + return html` +
    + + + +
    +
      + ${filtered.map((p) => html` +
    • + +
    • + `)} + ${total && !filtered.length ? html`

      No prompts match your search.

      ` : nothing} +
    + `; + } +} + +customElements.define('nx-prompts', NxPrompts); diff --git a/blocks/ew-chat/renderers.js b/blocks/ew-chat/renderers.js new file mode 100644 index 000000000..5f16311b9 --- /dev/null +++ b/blocks/ew-chat/renderers.js @@ -0,0 +1,119 @@ +import { html, nothing } from 'da-lit'; +import { AGENT_EVENT, ROLE, TOOL_INPUT, TOOL_STATE } from './constants.js'; +import { getNx } from '../shared/nxutils.js'; + +const { unified, remarkParse } = await import(`${getNx()}/deps/mdast/dist/index.js`); + +function renderNode(node) { + switch (node.type) { + case 'root': + return node.children.map(renderNode); + case 'paragraph': + return html`

    ${node.children.map(renderNode)}

    `; + case 'heading': + return html`${node.children.map(renderNode)}`; + case 'list': + return node.ordered + ? html`
      ${node.children.map(renderNode)}
    ` + : html`
      ${node.children.map(renderNode)}
    `; + case 'listItem': { + const children = node.spread + ? node.children.map(renderNode) + : node.children.flatMap((c) => (c.type === 'paragraph' ? c.children.map(renderNode) : [renderNode(c)])); + return html`
  • ${children}
  • `; + } + case 'strong': + return html`${node.children.map(renderNode)}`; + case 'emphasis': + return html`${node.children.map(renderNode)}`; + case 'inlineCode': + return html`${node.value}`; + case 'link': + return html`${node.children.map(renderNode)}`; + case 'text': + return node.value; + default: + return nothing; + } +} + +const parser = unified().use(remarkParse); + +function renderMessageContent(text) { + if (!text) return nothing; + const tree = parser.parse(text); + return renderNode(tree); +} + +function approvalSummary(input) { + if (!input) return null; + const { + HUMAN_READABLE_SUMMARY, SOURCE_PATH, DESTINATION_PATH, PATH, SKILL_ID, NAME, + } = TOOL_INPUT; + return input[HUMAN_READABLE_SUMMARY] + ?? (input[SOURCE_PATH] && input[DESTINATION_PATH] ? `${input[SOURCE_PATH]} → ${input[DESTINATION_PATH]}` : null) + ?? input[PATH] ?? input[SKILL_ID] ?? input[NAME] ?? null; +} + +function renderToolCard(toolCallId, toolCards) { + const card = toolCards?.get(toolCallId); + if (!card || card.state === TOOL_STATE.APPROVAL_REQUESTED) return nothing; + const { toolName, state, input } = card; + const detail = approvalSummary(input); + const failed = state === TOOL_STATE.ERROR || state === TOOL_STATE.REJECTED; + return html` +
    + ${toolName}${failed ? html`${state}` : nothing} + ${detail ? html`${detail}` : nothing} +
    + `; +} + +function renderApprovalCard(pending, onApprove) { + if (!pending) return nothing; + const { toolCallId, toolName, input } = pending; + const summary = approvalSummary(input); + return html` +
    + ${toolName} + ${summary ? html`${summary}` : nothing} +
    + + + +
    +
    + `; +} + +function renderMessage(msg, icons, toolCards) { + if (msg.role === ROLE.TOOL) return nothing; + const isAssistant = msg.role === ROLE.ASSISTANT; + + // Assistant message with tool-call parts (array content) + if (isAssistant && Array.isArray(msg.content)) { + return html`${msg.content.map((part) => (part.type === AGENT_EVENT.TOOL_CALL + ? renderToolCard(part.toolCallId, toolCards) + : nothing))}`; + } + + const copy = isAssistant && !msg.streaming + ? html`` + : nothing; + + return html` +
    +
    ${isAssistant ? renderMessageContent(msg.content) : msg.content}
    + ${copy} +
    + `; +} + +export { renderMessage, renderApprovalCard }; diff --git a/blocks/ew-chat/utils.js b/blocks/ew-chat/utils.js new file mode 100644 index 000000000..da37cc931 --- /dev/null +++ b/blocks/ew-chat/utils.js @@ -0,0 +1,81 @@ +import { AGENT_EVENT as EVENT } from './constants.js'; + +function processEvent(event, streaming, callbacks) { + const { onDelta, onText, onTool } = callbacks; + if (event.type === EVENT.ERROR) { + throw new Error(event.errorText ?? event.error?.message ?? 'Agent error'); + } + + if (event.type === EVENT.FINISH_MESSAGE || event.type === EVENT.FINISH) { + return { streaming, done: true }; + } + if (event.type === EVENT.TEXT_END) { + if (streaming) onText(streaming); + return { streaming: '', done: false }; + } + if (event.type === EVENT.TEXT_DELTA) { + const next = streaming + (event.delta ?? event.textDelta ?? event.text ?? ''); + onDelta(next); + return { streaming: next, done: false }; + } + + if (event.type === EVENT.TOOL_CALL || event.type === EVENT.TOOL_CALL_LEGACY) { + onTool?.({ + type: EVENT.TOOL_CALL, + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input ?? event.args ?? {}, + }); + } else if (event.type === EVENT.TOOL_APPROVAL_REQUEST) { + onTool?.({ + type: EVENT.TOOL_APPROVAL_REQUEST, + toolCallId: event.toolCallId, + toolName: event.toolName, + approvalId: event.approvalId, + input: event.input ?? event.args ?? {}, + }); + } else if (event.type === EVENT.TOOL_RESULT || event.type === EVENT.TOOL_RESULT_LEGACY) { + const raw = event.output ?? event.result; + const isError = raw && typeof raw === 'object' && 'error' in raw; + onTool?.({ + type: EVENT.TOOL_RESULT, + toolCallId: event.toolCallId, + toolName: event.toolName, + output: raw, + isError, + }); + } + + return { streaming, done: false }; +} + +export async function readStream(body, callbacks) { + const decoder = new TextDecoder(); + let buffer = ''; + let streaming = ''; + let finished = false; + + for await (const chunk of body) { + if (finished) break; + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const raw = line.startsWith('data: ') ? line.slice(6).trim() : line.trim(); + if (raw && raw !== '[DONE]') { + let event; + try { + event = JSON.parse(raw); + } catch { + event = null; + } + if (event) { + ({ streaming, done: finished } = processEvent(event, streaming, callbacks)); + } + } + } + } + + if (streaming) callbacks.onText(streaming); +} diff --git a/blocks/ew-chat/welcome/welcome.css b/blocks/ew-chat/welcome/welcome.css new file mode 100644 index 000000000..4e9155d40 --- /dev/null +++ b/blocks/ew-chat/welcome/welcome.css @@ -0,0 +1,80 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; + font-family: var(--s2-font-family); + font-size: var(--s2-body-size-s); + + button { + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--s2-gray-800); + } +} + +.chat-welcome-message { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + h3 { + color: var(--s2-gray-900); + font-size: var(--s2-heading-size-m); + } + + p { + margin: 0; + } +} + +.prompt-cards { + gap: var(--s2-spacing-75); + margin-top: var(--s2-spacing-200); + padding: 0 var(--s2-spacing-75); +} + +.prompt-card { + display: flex; + margin-bottom: var(--s2-spacing-100); + gap: var(--s2-spacing-300); + padding: var(--s2-spacing-100) var(--s2-spacing-300); + border: 1px solid var(--s2-gray-200); + border-radius: var(--s2-corner-radius-700); + background: var(--s2-gray-25); + background-color: var(--s2-gray-50); + align-items: center; + text-align: left; + + span { + line-height: 20px; + } + + &:hover { + background: var(--s2-gray-100); + border-color: var(--s2-gray-300); + } + + &::before { + content: ""; + display: block; + width: 16px; + height: 16px; + flex-shrink: 0; + background-color: currentcolor; + mask-image: url("/img/icons/s2-icon-aichat-20-n.svg"); + mask-size: contain; + mask-repeat: no-repeat; + } +} + +.prompt-more { + border: none; + background: none; + margin-top: var(--s2-spacing-100); + text-align: left; + width: fit-content; + padding: var(--s2-spacing-75) var(--s2-spacing-200); +} diff --git a/blocks/ew-chat/welcome/welcome.js b/blocks/ew-chat/welcome/welcome.js new file mode 100644 index 000000000..acfaf2133 --- /dev/null +++ b/blocks/ew-chat/welcome/welcome.js @@ -0,0 +1,50 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle } from '../../shared/nxutils.js'; +import { initIms as loadIms } from '../../shared/utils.js'; + +const styles = await loadStyle(import.meta.url); + +class NxChatWelcome extends LitElement { + static properties = { + prompts: { attribute: false }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [styles]; + loadIms().then(({ first_name: firstName, displayName }) => { + this._firstName = firstName ?? displayName?.split(' ')[0]; + this.requestUpdate(); + }); + } + + _showMore() { + this.dispatchEvent(new CustomEvent('nx-show-prompts', { bubbles: true, composed: true })); + } + + render() { + const greeting = `Welcome${this._firstName ? `, ${this._firstName}` : ''}`; + const prompts = this.prompts ?? []; + + return html` +
    +

    ${greeting}

    +

    What are we working on today?

    +
    + ${prompts.length ? html` +
    + ${prompts.slice(0, 3).map((card) => html` + + `)} +
    + ${prompts.length > 3 ? html` + + ` : nothing} + ` : nothing} + `; + } +} + +customElements.define('nx-chat-welcome', NxChatWelcome); diff --git a/blocks/ew-tool-panel/tool-panel.css b/blocks/ew-tool-panel/tool-panel.css new file mode 100644 index 000000000..b2914ec20 --- /dev/null +++ b/blocks/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 nx-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/ew-tool-panel/tool-panel.js b/blocks/ew-tool-panel/tool-panel.js new file mode 100644 index 000000000..fc7577b5e --- /dev/null +++ b/blocks/ew-tool-panel/tool-panel.js @@ -0,0 +1,207 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { loadStyle, getNx } from '../shared/nxutils.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'; + +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 firstUpdated() { + if (this.views?.length && !this.activeId) { + await this.showView(this.views[0].id); + } + } + + async updated(changed) { + if (changed.has('views')) await this._onViewsChange(); + if (changed.has('activeId')) { + 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)) { + await this.showView(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.showView(e.detail.value)} + > +
    +
    +
    + ${this._fullsizeDialogViewId ? html` + +
    +

    ${dialogTitle}

    + +
    +
    +
    + ` : nothing} + `; + } +} + +customElements.define('ew-tool-panel', EwToolPanel); diff --git a/blocks/inventory/inventory.js b/blocks/inventory/inventory.js index c7cb45a2d..5f4d059d3 100644 --- a/blocks/inventory/inventory.js +++ b/blocks/inventory/inventory.js @@ -195,8 +195,8 @@ export default function decorate(block) { position: 'before', width, getContent: async () => { - await import(`${getNx()}/blocks/chat/chat.js`); - return document.createElement('nx-chat'); + await import('../ew-chat/chat.js'); + return document.createElement('ew-chat'); }, }); }; 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 000000000..0bcb6cab5 --- /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 000000000..001abdaef --- /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 000000000..35f2e0aa0 --- /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 000000000..424d2dbba --- /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 000000000..190284b9c --- /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 000000000..eec3dc692 --- /dev/null +++ b/img/icons/s2-icon-close-20-n.svg @@ -0,0 +1,3 @@ + + + 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 000000000..766a32e4b --- /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 000000000..4f14e7b2f --- /dev/null +++ b/img/icons/s2-icon-paste-20-n.svg @@ -0,0 +1,4 @@ + + + + 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 000000000..f16b96535 --- /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 000000000..ced34c88c --- /dev/null +++ b/img/icons/s2-icon-search-20-n.svg @@ -0,0 +1,3 @@ + + + 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 000000000..1fd6ef8dd --- /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 000000000..4fd5b12b0 --- /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 000000000..4ce6b01d4 --- /dev/null +++ b/img/icons/s2-icon-stop-20-n.svg @@ -0,0 +1,3 @@ + + + From cc67e27cda0e0a86a5e8f5bc6638bb7c0ee4cffa Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Thu, 7 May 2026 12:05:41 +0200 Subject: [PATCH 03/19] remove nxutils file --- blocks/canvas-actions/canvas-actions.js | 7 +++-- blocks/canvas/canvas.js | 4 ++- .../editor-utils/nx-selection-toolbar.js | 3 ++- blocks/canvas/editor-utils/preview.js | 3 ++- .../nx-canvas-header/nx-canvas-header.js | 3 ++- blocks/canvas/nx-editor-doc/nx-editor-doc.js | 3 ++- .../prose-plugins/base64Uploader.js | 3 ++- .../prose-plugins/sourceUploadContext.js | 3 ++- blocks/canvas/nx-editor-doc/prose.js | 3 ++- .../nx-editor-doc/slash-menu/slash-menu.js | 2 +- .../nx-editor-doc/utils/load-editor-doc.js | 2 +- .../nx-editor-doc/utils/quick-edit-host.js | 2 +- blocks/canvas/nx-editor-doc/utils/source.js | 3 ++- .../canvas/nx-editor-split/nx-editor-split.js | 3 ++- .../nx-editor-wysiwyg/nx-editor-wysiwyg.js | 3 ++- .../canvas/nx-editor-wysiwyg/utils/image.js | 3 ++- .../canvas/nx-page-outline/nx-page-outline.js | 3 ++- .../canvas/nx-panel-extensions/aem-assets.js | 3 ++- blocks/canvas/nx-panel-extensions/helpers.js | 5 ++-- .../nx-panel-extensions/iframe-protocol.js | 2 +- .../nx-panel-extensions.js | 3 ++- .../nx-panel-extensions/nx-panel-library.js | 3 ++- .../canvas/nx-panel-header/nx-panel-header.js | 3 ++- blocks/ew-chat/api.js | 3 ++- blocks/ew-chat/chat.js | 3 ++- blocks/ew-chat/pills/pills.js | 3 ++- blocks/ew-chat/prompts/prompts.js | 3 ++- blocks/ew-chat/renderers.js | 2 +- blocks/ew-chat/welcome/welcome.js | 3 ++- blocks/ew-tool-panel/tool-panel.js | 3 ++- blocks/inventory/browse-api.js | 3 ++- blocks/inventory/inventory.js | 6 ++--- blocks/inventory/list/list.js | 3 ++- blocks/shared/nxutils.js | 27 ------------------- 34 files changed, 65 insertions(+), 66 deletions(-) delete mode 100644 blocks/shared/nxutils.js diff --git a/blocks/canvas-actions/canvas-actions.js b/blocks/canvas-actions/canvas-actions.js index 781c85740..a30f279df 100644 --- a/blocks/canvas-actions/canvas-actions.js +++ b/blocks/canvas-actions/canvas-actions.js @@ -1,9 +1,8 @@ import { LitElement, html, nothing } from 'da-lit'; -import { - getNx, loadStyle, HashController, - buildAemPathFromHashState, formatAemPreviewPublishError, runAemPreviewOrPublish, -} from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; +const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); +const { buildAemPathFromHashState, formatAemPreviewPublishError, runAemPreviewOrPublish } = await import(`${getNx()}/utils/aem-preview-publish.js`); await import(`${getNx()}/blocks/shared/popover/popover.js`); diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js index 6b674721e..b340ade4c 100644 --- a/blocks/canvas/canvas.js +++ b/blocks/canvas/canvas.js @@ -1,4 +1,6 @@ -import { loadStyle, hashChange, getPanelStore, openPanel } from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; +const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); +const { getPanelStore, openPanel } = await import(`${getNx()}/utils/panel.js`); import './nx-canvas-header/nx-canvas-header.js'; import './nx-editor-doc/nx-editor-doc.js'; import './nx-editor-wysiwyg/nx-editor-wysiwyg.js'; diff --git a/blocks/canvas/editor-utils/nx-selection-toolbar.js b/blocks/canvas/editor-utils/nx-selection-toolbar.js index 5dc7bffd2..b2d82f137 100644 --- a/blocks/canvas/editor-utils/nx-selection-toolbar.js +++ b/blocks/canvas/editor-utils/nx-selection-toolbar.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { getNx, loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.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`); diff --git a/blocks/canvas/editor-utils/preview.js b/blocks/canvas/editor-utils/preview.js index 35dfa62f3..fbfbf722e 100644 --- a/blocks/canvas/editor-utils/preview.js +++ b/blocks/canvas/editor-utils/preview.js @@ -1,4 +1,5 @@ -import { DA_CONTENT } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); import { daFetch } from '../../shared/utils.js'; export function getPreviewOrigin(org, repo) { diff --git a/blocks/canvas/nx-canvas-header/nx-canvas-header.js b/blocks/canvas/nx-canvas-header/nx-canvas-header.js index b8f72fad0..168762c85 100644 --- a/blocks/canvas/nx-canvas-header/nx-canvas-header.js +++ b/blocks/canvas/nx-canvas-header/nx-canvas-header.js @@ -1,6 +1,7 @@ import { LitElement, html } from 'da-lit'; -import { loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); const style = await loadStyle(import.meta.url); diff --git a/blocks/canvas/nx-editor-doc/nx-editor-doc.js b/blocks/canvas/nx-editor-doc/nx-editor-doc.js index 2470fcfcf..eb5994f30 100644 --- a/blocks/canvas/nx-editor-doc/nx-editor-doc.js +++ b/blocks/canvas/nx-editor-doc/nx-editor-doc.js @@ -1,6 +1,7 @@ import { LitElement, html, nothing } from 'da-lit'; import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper'; -import { loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); import { updateDocument, updateCursors, getInstrumentedHTML, editorHtmlChange, editorSelectChange } from '../editor-utils/document.js'; import { getActiveBlockFlatIndex, getBlockPositions } from '../nx-editor-wysiwyg/utils/blocks.js'; import { getEditor } from '../editor-utils/state.js'; diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js index e5f504c14..4d6db9e96 100644 --- a/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js +++ b/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js @@ -1,5 +1,6 @@ import { Plugin } from 'da-y-wrapper'; -import { DA_ADMIN, DA_CONTENT } from '../../../shared/nxutils.js'; +import { getNx } from '../../../../scripts/utils.js'; +const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); import { daFetch } from '../../../shared/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js index 64042f67e..ec9501dc9 100644 --- a/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js +++ b/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js @@ -2,7 +2,8 @@ * Copyright 2026 Adobe. All rights reserved. * Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails). */ -import { DA_ADMIN } from '../../../shared/nxutils.js'; +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 diff --git a/blocks/canvas/nx-editor-doc/prose.js b/blocks/canvas/nx-editor-doc/prose.js index f09d54d14..7ff371119 100644 --- a/blocks/canvas/nx-editor-doc/prose.js +++ b/blocks/canvas/nx-editor-doc/prose.js @@ -37,7 +37,8 @@ import imageDrop from './prose-plugins/imageDrop.js'; import imageFocalPoint from './prose-plugins/imageFocalPoint.js'; import sectionPasteHandler from './prose-plugins/sectionPasteHandler.js'; import base64Uploader from './prose-plugins/base64Uploader.js'; -import { DA_ADMIN, DA_COLLAB } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { DA_ADMIN, DA_COLLAB } = await import(`${getNx()}/utils/utils.js`); import { generateColor, getCollabIdentity } from './utils/collab.js'; function registerErrorHandler(ydoc) { diff --git a/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js b/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js index 18f9f9632..d84f57a02 100644 --- a/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js +++ b/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js @@ -1,6 +1,6 @@ /* eslint-disable import/no-unresolved -- importmap */ import { Plugin } from 'da-y-wrapper'; -import { getNx } from '../../../shared/nxutils.js'; +import { getNx } from '../../../../scripts/utils.js'; await import(`${getNx()}/blocks/shared/menu/menu.js`); import { slashMenuItemsForQuery, COMMAND_BY_ID } from '../../editor-utils/command-defs.js'; diff --git a/blocks/canvas/nx-editor-doc/utils/load-editor-doc.js b/blocks/canvas/nx-editor-doc/utils/load-editor-doc.js index fd7a0a67f..751ba467a 100644 --- a/blocks/canvas/nx-editor-doc/utils/load-editor-doc.js +++ b/blocks/canvas/nx-editor-doc/utils/load-editor-doc.js @@ -1,5 +1,5 @@ import { checkDoc } from './source.js'; -import { getNx } from '../../../shared/nxutils.js'; +import { getNx } from '../../../../scripts/utils.js'; export async function resolveEditorDocSession(sourceUrl) { const { loadIms } = await import(`${getNx()}/utils/ims.js`); diff --git a/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js b/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js index 82379d9b0..a469e8a42 100644 --- a/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js +++ b/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js @@ -1,5 +1,5 @@ import { createControllerOnMessage } from '../../nx-editor-wysiwyg/quick-edit-controller.js'; -import { getNx } from '../../../shared/nxutils.js'; +import { getNx } from '../../../../scripts/utils.js'; import { updateDocument, updateCursors } from '../../editor-utils/document.js'; import { fetchWysiwygCookie } from '../../editor-utils/preview.js'; diff --git a/blocks/canvas/nx-editor-doc/utils/source.js b/blocks/canvas/nx-editor-doc/utils/source.js index 8c377c8ad..7dd17ab2b 100644 --- a/blocks/canvas/nx-editor-doc/utils/source.js +++ b/blocks/canvas/nx-editor-doc/utils/source.js @@ -1,4 +1,5 @@ -import { DA_ADMIN } from '../../../shared/nxutils.js'; +import { getNx } from '../../../../scripts/utils.js'; +const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); import { daFetch } from '../../../shared/utils.js'; export function buildSourceUrl(path) { diff --git a/blocks/canvas/nx-editor-split/nx-editor-split.js b/blocks/canvas/nx-editor-split/nx-editor-split.js index cf44188e1..04eabffa0 100644 --- a/blocks/canvas/nx-editor-split/nx-editor-split.js +++ b/blocks/canvas/nx-editor-split/nx-editor-split.js @@ -1,4 +1,5 @@ -import { loadStyle } from '../../shared/nxutils.js'; +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]; diff --git a/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js b/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js index 0465d4fb7..f4faebb1b 100644 --- a/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js +++ b/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js @@ -1,5 +1,6 @@ import { LitElement, html } from 'da-lit'; -import { loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); import { getPreviewOrigin, fetchWysiwygCookie } from '../editor-utils/preview.js'; import { initIms as loadIms } from '../../shared/utils.js'; import { hideSelectionToolbar } from '../editor-utils/selection-toolbar.js'; diff --git a/blocks/canvas/nx-editor-wysiwyg/utils/image.js b/blocks/canvas/nx-editor-wysiwyg/utils/image.js index d0529fe9b..e7d37f66f 100644 --- a/blocks/canvas/nx-editor-wysiwyg/utils/image.js +++ b/blocks/canvas/nx-editor-wysiwyg/utils/image.js @@ -1,4 +1,5 @@ -import { DA_ADMIN, DA_CONTENT } from '../../../shared/nxutils.js'; +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; diff --git a/blocks/canvas/nx-page-outline/nx-page-outline.js b/blocks/canvas/nx-page-outline/nx-page-outline.js index cb316fdee..1a24cc2b6 100644 --- a/blocks/canvas/nx-page-outline/nx-page-outline.js +++ b/blocks/canvas/nx-page-outline/nx-page-outline.js @@ -1,5 +1,6 @@ import { LitElement, html } from 'da-lit'; -import { loadStyle, HashController } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); import { editorHtmlChange, editorSelectChange } from '../editor-utils/document.js'; const style = await loadStyle(import.meta.url); diff --git a/blocks/canvas/nx-panel-extensions/aem-assets.js b/blocks/canvas/nx-panel-extensions/aem-assets.js index 1f21712e6..b88736219 100644 --- a/blocks/canvas/nx-panel-extensions/aem-assets.js +++ b/blocks/canvas/nx-panel-extensions/aem-assets.js @@ -1,6 +1,7 @@ /* eslint-disable import/no-unresolved -- importmap */ import { DOMParser as PMDOMParser } from 'da-y-wrapper'; -import { getNx, fetchDaConfigs, getFirstSheet } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`); import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; const ASSET_SELECTOR_URL = 'https://experience.adobe.com/solutions/CQ-assets-selectors/static-assets/resources/assets-selectors.js'; diff --git a/blocks/canvas/nx-panel-extensions/helpers.js b/blocks/canvas/nx-panel-extensions/helpers.js index 82e8c3071..6dde9a887 100644 --- a/blocks/canvas/nx-panel-extensions/helpers.js +++ b/blocks/canvas/nx-panel-extensions/helpers.js @@ -1,8 +1,9 @@ /* eslint-disable import/no-unresolved -- importmap */ import { DOMParser as PMDOMParser, DOMSerializer, Slice, TextSelection } from 'da-y-wrapper'; -import { HLX_ADMIN, hashChange } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { HLX_ADMIN, hashChange } = await import(`${getNx()}/utils/utils.js`); import { daFetch } from '../../shared/utils.js'; -import { fetchDaConfigs, getFirstSheet } from '../../shared/nxutils.js'; +const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`); import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; const ref = new URLSearchParams(window.location.search).get('ref') || 'main'; diff --git a/blocks/canvas/nx-panel-extensions/iframe-protocol.js b/blocks/canvas/nx-panel-extensions/iframe-protocol.js index 43fca5be3..c2a35ce76 100644 --- a/blocks/canvas/nx-panel-extensions/iframe-protocol.js +++ b/blocks/canvas/nx-panel-extensions/iframe-protocol.js @@ -1,5 +1,5 @@ import { insertText, insertHTML, getEditorSelection } from './helpers.js'; -import { getNx } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; /** * Wire a two-way MessageChannel between the host and a BYO plugin iframe. diff --git a/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js b/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js index ecc7c0a0a..9eaef047d 100644 --- a/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js +++ b/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { loadStyle, HashController } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; import './nx-panel-library.js'; diff --git a/blocks/canvas/nx-panel-extensions/nx-panel-library.js b/blocks/canvas/nx-panel-extensions/nx-panel-library.js index 1fc3e7742..f41460fd8 100644 --- a/blocks/canvas/nx-panel-extensions/nx-panel-library.js +++ b/blocks/canvas/nx-panel-extensions/nx-panel-library.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { loadStyle, HashController } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); import { fetchBlocks, fetchItems, diff --git a/blocks/canvas/nx-panel-header/nx-panel-header.js b/blocks/canvas/nx-panel-header/nx-panel-header.js index 27582e4da..ab37f4631 100644 --- a/blocks/canvas/nx-panel-header/nx-panel-header.js +++ b/blocks/canvas/nx-panel-header/nx-panel-header.js @@ -1,4 +1,5 @@ -import { loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); const style = await loadStyle(import.meta.url); diff --git a/blocks/ew-chat/api.js b/blocks/ew-chat/api.js index 58172c332..a23117ff8 100644 --- a/blocks/ew-chat/api.js +++ b/blocks/ew-chat/api.js @@ -1,4 +1,5 @@ -import { DA_ADMIN } from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; +const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); import { daFetch } from '../shared/utils.js'; export async function loadPrompts(org, site) { diff --git a/blocks/ew-chat/chat.js b/blocks/ew-chat/chat.js index 10a64aab6..02bda1fd8 100644 --- a/blocks/ew-chat/chat.js +++ b/blocks/ew-chat/chat.js @@ -4,7 +4,8 @@ import { renderMessage, renderApprovalCard } from './renderers.js'; import './welcome/welcome.js'; import './prompts/prompts.js'; import './pills/pills.js'; -import { loadStyle, hashChange, getNx } from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; +const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); import { loadPrompts } from './api.js'; import { ADD_MENU_ITEMS, MENU_OPTIONS, ROLE, TOOL_STATE } from './constants.js'; diff --git a/blocks/ew-chat/pills/pills.js b/blocks/ew-chat/pills/pills.js index 26aa65338..192ab7db5 100644 --- a/blocks/ew-chat/pills/pills.js +++ b/blocks/ew-chat/pills/pills.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); const styles = await loadStyle(import.meta.url); diff --git a/blocks/ew-chat/prompts/prompts.js b/blocks/ew-chat/prompts/prompts.js index b5eb83af6..29cd3ed54 100644 --- a/blocks/ew-chat/prompts/prompts.js +++ b/blocks/ew-chat/prompts/prompts.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { loadStyle, getNx } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); await import(`${getNx()}/blocks/shared/picker/picker.js`); diff --git a/blocks/ew-chat/renderers.js b/blocks/ew-chat/renderers.js index 5f16311b9..205f5abaf 100644 --- a/blocks/ew-chat/renderers.js +++ b/blocks/ew-chat/renderers.js @@ -1,6 +1,6 @@ import { html, nothing } from 'da-lit'; import { AGENT_EVENT, ROLE, TOOL_INPUT, TOOL_STATE } from './constants.js'; -import { getNx } from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; const { unified, remarkParse } = await import(`${getNx()}/deps/mdast/dist/index.js`); diff --git a/blocks/ew-chat/welcome/welcome.js b/blocks/ew-chat/welcome/welcome.js index acfaf2133..2176bd047 100644 --- a/blocks/ew-chat/welcome/welcome.js +++ b/blocks/ew-chat/welcome/welcome.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); import { initIms as loadIms } from '../../shared/utils.js'; const styles = await loadStyle(import.meta.url); diff --git a/blocks/ew-tool-panel/tool-panel.js b/blocks/ew-tool-panel/tool-panel.js index fc7577b5e..5669cece4 100644 --- a/blocks/ew-tool-panel/tool-panel.js +++ b/blocks/ew-tool-panel/tool-panel.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { loadStyle, getNx } from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); await import(`${getNx()}/blocks/shared/picker/picker.js`); diff --git a/blocks/inventory/browse-api.js b/blocks/inventory/browse-api.js index 6b5fca116..6508e60a8 100644 --- a/blocks/inventory/browse-api.js +++ b/blocks/inventory/browse-api.js @@ -1,4 +1,5 @@ -import { DA_ADMIN } from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; +const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); import { daFetch } from '../shared/utils.js'; /** diff --git a/blocks/inventory/inventory.js b/blocks/inventory/inventory.js index 5f4d059d3..e6f14441b 100644 --- a/blocks/inventory/inventory.js +++ b/blocks/inventory/inventory.js @@ -1,7 +1,7 @@ import { LitElement, html, nothing } from 'da-lit'; -import { - getNx, loadStyle, hashChange, getPanelStore, openPanel, -} from '../shared/nxutils.js'; +import { getNx } from '../../scripts/utils.js'; +const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); +const { getPanelStore, openPanel } = await import(`${getNx()}/utils/panel.js`); import { listFolder } from './browse-api.js'; import { contextToPathContext, diff --git a/blocks/inventory/list/list.js b/blocks/inventory/list/list.js index 2f9f73a2d..e74d23478 100644 --- a/blocks/inventory/list/list.js +++ b/blocks/inventory/list/list.js @@ -1,5 +1,6 @@ import { LitElement, html, nothing } from 'da-lit'; -import { loadStyle } from '../../shared/nxutils.js'; +import { getNx } from '../../../scripts/utils.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); import { formatColumnLastModified } from './format.js'; import { getIconByExtension, diff --git a/blocks/shared/nxutils.js b/blocks/shared/nxutils.js deleted file mode 100644 index ec8b2c9d0..000000000 --- a/blocks/shared/nxutils.js +++ /dev/null @@ -1,27 +0,0 @@ -import { getNx } from '../../scripts/utils.js'; - -const { - DA_ADMIN, DA_COLLAB, DA_CONTENT, DA_ETC, DA_PREVIEW, - HLX_ADMIN, AEM_API, ALLOWED_TOKEN, - hashChange, loadStyle, loadPageStyle, HashController, getEnv, -} = await import(`${getNx()}/utils/utils.js`); - -const { openPanel, getPanelStore, closePanel } = await import(`${getNx()}/utils/panel.js`); -const { loadHrefSvg, ICONS_BASE } = await import(`${getNx()}/utils/svg.js`); -const { fetchDaConfigs, getFirstSheet } = await import(`${getNx()}/utils/daConfig.js`); -const { - buildAemPathFromHashState, - formatAemPreviewPublishError, - runAemPreviewOrPublish, -} = await import(`${getNx()}/utils/aem-preview-publish.js`); - -export { - getNx, - DA_ADMIN, DA_COLLAB, DA_CONTENT, DA_ETC, DA_PREVIEW, - HLX_ADMIN, AEM_API, ALLOWED_TOKEN, - hashChange, loadStyle, loadPageStyle, HashController, getEnv, - openPanel, getPanelStore, closePanel, - loadHrefSvg, ICONS_BASE, - fetchDaConfigs, getFirstSheet, - buildAemPathFromHashState, formatAemPreviewPublishError, runAemPreviewOrPublish, -}; From 58d0fadd1aa8bd5a9f5ddb81722a801723e46ff1 Mon Sep 17 00:00:00 2001 From: Hannes Hertach Date: Thu, 7 May 2026 13:47:42 +0200 Subject: [PATCH 04/19] rename files --- blocks/canvas/canvas.css | 20 ++-- blocks/canvas/canvas.js | 28 ++--- blocks/canvas/editor-utils/command-defs.js | 2 +- .../canvas/editor-utils/selection-toolbar.js | 8 +- .../ew-canvas-header.css} | 2 +- .../ew-canvas-header.js} | 4 +- .../ew-editor-doc.css} | 102 +++++++++--------- .../ew-editor-doc.js} | 20 ++-- .../prose-plugins/base64Uploader.js | 0 .../prose-plugins/codemark.js | 0 .../prose-plugins/focalPointDialog.js | 0 .../prose-plugins/imageDrop.js | 0 .../prose-plugins/imageFocalPoint.js | 0 .../prose-plugins/inlinesvg.js | 0 .../prose-plugins/sectionPasteHandler.js | 0 .../prose-plugins/sourceUploadContext.js | 0 .../prose-plugins/tableSelectHandle.js | 0 .../prose-plugins/tableUtils.js | 0 .../{nx-editor-doc => ew-editor-doc}/prose.js | 0 .../slash-menu/slash-menu.js | 2 +- .../utils/awareness-users.js | 0 .../utils/collab.js | 0 .../utils/ctx.js | 0 .../utils/load-editor-doc.js | 0 .../utils/quick-edit-host.js | 2 +- .../utils/shadow-mount.js | 2 +- .../utils/source.js | 0 .../utils/teardown.js | 0 .../ew-editor-split.css} | 4 +- .../ew-editor-split.js} | 4 +- .../ew-editor-wysiwyg.css} | 8 +- .../ew-editor-wysiwyg.js} | 20 ++-- .../quick-edit-controller.js | 0 .../utils/blocks.js | 0 .../utils/handlers.js | 0 .../utils/image.js | 0 .../ew-page-outline.css} | 22 ++-- .../ew-page-outline.js} | 30 +++--- .../aem-assets.js | 0 .../ew-panel-extensions.css} | 0 .../ew-panel-extensions.js} | 8 +- .../ew-panel-library.css} | 0 .../ew-panel-library.js} | 4 +- .../helpers.js | 8 +- .../iframe-protocol.js | 0 .../ew-panel-header.css} | 0 .../ew-panel-header.js} | 0 .../ew-selection-toolbar.css} | 0 .../ew-selection-toolbar.js} | 10 +- blocks/ew-tool-panel/tool-panel.css | 2 +- 50 files changed, 156 insertions(+), 156 deletions(-) rename blocks/canvas/{nx-canvas-header/nx-canvas-header.css => ew-canvas-header/ew-canvas-header.css} (98%) rename blocks/canvas/{nx-canvas-header/nx-canvas-header.js => ew-canvas-header/ew-canvas-header.js} (97%) rename blocks/canvas/{nx-editor-doc/nx-editor-doc.css => ew-editor-doc/ew-editor-doc.css} (80%) rename blocks/canvas/{nx-editor-doc/nx-editor-doc.js => ew-editor-doc/ew-editor-doc.js} (94%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/base64Uploader.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/codemark.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/focalPointDialog.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/imageDrop.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/imageFocalPoint.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/inlinesvg.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/sectionPasteHandler.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/sourceUploadContext.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/tableSelectHandle.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose-plugins/tableUtils.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/prose.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/slash-menu/slash-menu.js (98%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/awareness-users.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/collab.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/ctx.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/load-editor-doc.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/quick-edit-host.js (93%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/shadow-mount.js (92%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/source.js (100%) rename blocks/canvas/{nx-editor-doc => ew-editor-doc}/utils/teardown.js (100%) rename blocks/canvas/{nx-editor-split/nx-editor-split.css => ew-editor-split/ew-editor-split.css} (90%) rename blocks/canvas/{nx-editor-split/nx-editor-split.js => ew-editor-split/ew-editor-split.js} (97%) rename blocks/canvas/{nx-editor-wysiwyg/nx-editor-wysiwyg.css => ew-editor-wysiwyg/ew-editor-wysiwyg.css} (79%) rename blocks/canvas/{nx-editor-wysiwyg/nx-editor-wysiwyg.js => ew-editor-wysiwyg/ew-editor-wysiwyg.js} (91%) rename blocks/canvas/{nx-editor-wysiwyg => ew-editor-wysiwyg}/quick-edit-controller.js (100%) rename blocks/canvas/{nx-editor-wysiwyg => ew-editor-wysiwyg}/utils/blocks.js (100%) rename blocks/canvas/{nx-editor-wysiwyg => ew-editor-wysiwyg}/utils/handlers.js (100%) rename blocks/canvas/{nx-editor-wysiwyg => ew-editor-wysiwyg}/utils/image.js (100%) rename blocks/canvas/{nx-page-outline/nx-page-outline.css => ew-page-outline/ew-page-outline.css} (85%) rename blocks/canvas/{nx-page-outline/nx-page-outline.js => ew-page-outline/ew-page-outline.js} (84%) rename blocks/canvas/{nx-panel-extensions => ew-panel-extensions}/aem-assets.js (100%) rename blocks/canvas/{nx-panel-extensions/nx-panel-extensions.css => ew-panel-extensions/ew-panel-extensions.css} (100%) rename blocks/canvas/{nx-panel-extensions/nx-panel-extensions.js => ew-panel-extensions/ew-panel-extensions.js} (87%) rename blocks/canvas/{nx-panel-extensions/nx-panel-library.css => ew-panel-extensions/ew-panel-library.css} (100%) rename blocks/canvas/{nx-panel-extensions/nx-panel-library.js => ew-panel-extensions/ew-panel-library.js} (98%) rename blocks/canvas/{nx-panel-extensions => ew-panel-extensions}/helpers.js (98%) rename blocks/canvas/{nx-panel-extensions => ew-panel-extensions}/iframe-protocol.js (100%) rename blocks/canvas/{nx-panel-header/nx-panel-header.css => ew-panel-header/ew-panel-header.css} (100%) rename blocks/canvas/{nx-panel-header/nx-panel-header.js => ew-panel-header/ew-panel-header.js} (100%) rename blocks/canvas/{editor-utils/nx-selection-toolbar.css => ew-selection-toolbar/ew-selection-toolbar.css} (100%) rename blocks/canvas/{editor-utils/nx-selection-toolbar.js => ew-selection-toolbar/ew-selection-toolbar.js} (96%) diff --git a/blocks/canvas/canvas.css b/blocks/canvas/canvas.css index d87ae17bb..d76600106 100644 --- a/blocks/canvas/canvas.css +++ b/blocks/canvas/canvas.css @@ -1,12 +1,12 @@ :root { - --nx-canvas-header-height: 48px; + --ew-canvas-header-height: 48px; } -html:has(aside.panel[data-position="before"]:not([hidden])) nx-canvas-header::part(toggle-before) { +html:has(aside.panel[data-position="before"]:not([hidden])) ew-canvas-header::part(toggle-before) { display: none; } -html:has(aside.panel[data-position="after"]:not([hidden])) nx-canvas-header::part(toggle-after) { +html:has(aside.panel[data-position="after"]:not([hidden])) ew-canvas-header::part(toggle-after) { display: none; } @@ -33,25 +33,25 @@ nx-chat { flex: 1; min-height: 0; gap: 0; - height: calc(100vh - var(--nx-canvas-header-height) - var(--s2-nav-height)); + height: calc(100vh - var(--ew-canvas-header-height) - var(--s2-nav-height)); } -nx-editor-doc { +ew-editor-doc { display: block; flex: 1; min-height: 0; - max-height: calc(100vh - var(--nx-canvas-header-height) - var(--s2-nav-height)); + max-height: calc(100vh - var(--ew-canvas-header-height) - var(--s2-nav-height)); overflow-y: auto; } -nx-editor-wysiwyg, -nx-editor-doc { +ew-editor-wysiwyg, +ew-editor-doc { contain: layout; overflow: hidden; } -nx-editor-doc[hidden], -nx-editor-wysiwyg[hidden] { +ew-editor-doc[hidden], +ew-editor-wysiwyg[hidden] { display: none !important; } diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js index b340ade4c..09de3cb12 100644 --- a/blocks/canvas/canvas.js +++ b/blocks/canvas/canvas.js @@ -1,15 +1,15 @@ import { getNx } from '../../scripts/utils.js'; const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); const { getPanelStore, openPanel } = await import(`${getNx()}/utils/panel.js`); -import './nx-canvas-header/nx-canvas-header.js'; -import './nx-editor-doc/nx-editor-doc.js'; -import './nx-editor-wysiwyg/nx-editor-wysiwyg.js'; +import './ew-canvas-header/ew-canvas-header.js'; +import './ew-editor-doc/ew-editor-doc.js'; +import './ew-editor-wysiwyg/ew-editor-wysiwyg.js'; import { syncEditorSplitLayout, finalizeSplitEditorMountOrder, installEditorSplitDrag, removeSplitGutter, -} from './nx-editor-split/nx-editor-split.js'; +} from './ew-editor-split/ew-editor-split.js'; const style = await loadStyle(import.meta.url); document.adoptedStyleSheets = [...document.adoptedStyleSheets, style]; @@ -64,23 +64,23 @@ function canvasHeaderApplyTarget(block) { function removeCanvasEditors(mountRoot) { removeSplitGutter(mountRoot); - mountRoot.querySelector('nx-editor-doc')?.remove(); - mountRoot.querySelector('nx-editor-wysiwyg')?.remove(); + mountRoot.querySelector('ew-editor-doc')?.remove(); + mountRoot.querySelector('ew-editor-wysiwyg')?.remove(); } function ensureNxEditorDoc(mountRoot) { - let el = mountRoot.querySelector('nx-editor-doc'); + let el = mountRoot.querySelector('ew-editor-doc'); if (!el) { - el = document.createElement('nx-editor-doc'); + el = document.createElement('ew-editor-doc'); mountRoot.append(el); } return el; } function ensureNxEditorWysiwyg(mountRoot) { - let frame = mountRoot.querySelector('nx-editor-wysiwyg'); + let frame = mountRoot.querySelector('ew-editor-wysiwyg'); if (!frame) { - frame = document.createElement('nx-editor-wysiwyg'); + frame = document.createElement('ew-editor-wysiwyg'); mountRoot.append(frame); } return frame; @@ -116,7 +116,7 @@ async function syncToolPanelViews(toolPanel, { org, site }) { return; } - const { getCanvasToolPanelViews } = await import('./nx-panel-extensions/helpers.js'); + const { getCanvasToolPanelViews } = await import('./ew-panel-extensions/helpers.js'); const views = await getCanvasToolPanelViews({ org, site }); if (toolPanel.dataset.extKey !== key) return; toolPanel.views = views; @@ -163,7 +163,7 @@ async function openCanvasPanel(position, { preferredViewId } = {}) { } function installCanvasHeader(block) { - const header = document.createElement('nx-canvas-header'); + const header = document.createElement('ew-canvas-header'); header.editorView = readPersistedCanvasEditorView(); header.addEventListener('nx-canvas-open-panel', (e) => { openCanvasPanel(e.detail.position, { preferredViewId: e.detail.viewId }); @@ -176,10 +176,10 @@ function installCanvasHeader(block) { syncEditorSplitLayout({ mountRoot: canvasEditorMountRoot(block), view }); }); header.addEventListener('nx-canvas-undo', () => { - canvasEditorMountRoot(block).querySelector('nx-editor-doc')?.undo(); + canvasEditorMountRoot(block).querySelector('ew-editor-doc')?.undo(); }); header.addEventListener('nx-canvas-redo', () => { - canvasEditorMountRoot(block).querySelector('nx-editor-doc')?.redo(); + canvasEditorMountRoot(block).querySelector('ew-editor-doc')?.redo(); }); block.before(header); return header; diff --git a/blocks/canvas/editor-utils/command-defs.js b/blocks/canvas/editor-utils/command-defs.js index 39af5dca5..5a2c8d8cc 100644 --- a/blocks/canvas/editor-utils/command-defs.js +++ b/blocks/canvas/editor-utils/command-defs.js @@ -219,7 +219,7 @@ export const COMMANDS = [ composed: true, detail: { position: 'after', viewId: 'blocks' }, }); - document.querySelector('nx-canvas-header')?.dispatchEvent(evt); + document.querySelector('ew-canvas-header')?.dispatchEvent(evt); }, }, { diff --git a/blocks/canvas/editor-utils/selection-toolbar.js b/blocks/canvas/editor-utils/selection-toolbar.js index 49ef85e5a..2638ade98 100644 --- a/blocks/canvas/editor-utils/selection-toolbar.js +++ b/blocks/canvas/editor-utils/selection-toolbar.js @@ -22,8 +22,8 @@ let componentLoaded; export function getSelectionToolbar() { if (toolbar) return toolbar; - componentLoaded ??= import('./nx-selection-toolbar.js'); - toolbar = document.createElement('nx-selection-toolbar'); + componentLoaded ??= import('../ew-selection-toolbar/ew-selection-toolbar.js'); + toolbar = document.createElement('ew-selection-toolbar'); document.body.append(toolbar); return toolbar; } @@ -75,10 +75,10 @@ export function createSelectionToolbarPlugin() { return { update(view) { if (!scrollEl) { - scrollEl = view.dom.closest('.nx-editor-doc'); + scrollEl = view.dom.closest('.ew-editor-doc'); scrollEl?.addEventListener('scroll', onScroll, { passive: true }); } - const header = document.querySelector('nx-canvas-header'); + const header = document.querySelector('ew-canvas-header'); const ev = header?.editorView; if (ev !== 'content' && ev !== 'split') return; if (getSelectionOriginFromIframe(view.state)) return; diff --git a/blocks/canvas/nx-canvas-header/nx-canvas-header.css b/blocks/canvas/ew-canvas-header/ew-canvas-header.css similarity index 98% rename from blocks/canvas/nx-canvas-header/nx-canvas-header.css rename to blocks/canvas/ew-canvas-header/ew-canvas-header.css index 826ffa768..a3570e81b 100644 --- a/blocks/canvas/nx-canvas-header/nx-canvas-header.css +++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.css @@ -124,7 +124,7 @@ justify-content: space-between; gap: 12px; box-sizing: border-box; - height: var(--nx-canvas-header-height); + 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); diff --git a/blocks/canvas/nx-canvas-header/nx-canvas-header.js b/blocks/canvas/ew-canvas-header/ew-canvas-header.js similarity index 97% rename from blocks/canvas/nx-canvas-header/nx-canvas-header.js rename to blocks/canvas/ew-canvas-header/ew-canvas-header.js index 168762c85..3fb9720e1 100644 --- a/blocks/canvas/nx-canvas-header/nx-canvas-header.js +++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.js @@ -15,7 +15,7 @@ const ICONS = { const EDITOR_VIEWS = /** @type {const} */ (['layout', 'content', 'split']); -class NXCanvasHeader extends LitElement { +class EWCanvasHeader extends LitElement { static properties = { /** `'layout'` / `'content'` = single pane; `'split'` = doc + WYSIWYG side by side */ editorView: { type: String, reflect: true }, @@ -131,4 +131,4 @@ class NXCanvasHeader extends LitElement { } } -customElements.define('nx-canvas-header', NXCanvasHeader); +customElements.define('ew-canvas-header', EWCanvasHeader); diff --git a/blocks/canvas/nx-editor-doc/nx-editor-doc.css b/blocks/canvas/ew-editor-doc/ew-editor-doc.css similarity index 80% rename from blocks/canvas/nx-editor-doc/nx-editor-doc.css rename to blocks/canvas/ew-editor-doc/ew-editor-doc.css index 373dda075..98ce152de 100644 --- a/blocks/canvas/nx-editor-doc/nx-editor-doc.css +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.css @@ -1,6 +1,6 @@ /* stylelint-disable selector-class-pattern -- ProseMirror and Yjs use their own class names */ -.nx-editor-doc { +.ew-editor-doc { display: flex; flex-direction: column; height: 100%; @@ -8,20 +8,20 @@ overflow: hidden auto; } -.nx-editor-doc .nx-editor-doc-mount { +.ew-editor-doc .ew-editor-doc-mount { flex: 1; min-height: 0; max-width: 800px; margin: auto; } -.nx-editor-doc .da-prose-mirror { +.ew-editor-doc .da-prose-mirror { flex: 1; min-height: 0; position: relative; } -.nx-editor-doc .ProseMirror { +.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)); @@ -32,11 +32,11 @@ box-sizing: border-box; } -.nx-editor-doc .ProseMirror-focused { +.ew-editor-doc .ProseMirror-focused { outline: none; } -.nx-editor-doc .da-slash-hint { +.ew-editor-doc .da-slash-hint { position: absolute; pointer-events: none; color: var(--s2-gray-500, rgb(143 143 143)); @@ -44,18 +44,18 @@ user-select: none; } -.nx-editor-doc .ProseMirror > *:first-child { +.ew-editor-doc .ProseMirror > *:first-child { margin-top: 0; } -.nx-editor-doc .ProseMirror img { +.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. */ -.nx-editor-doc .ProseMirror table { +.ew-editor-doc .ProseMirror table { border-collapse: collapse; table-layout: fixed; width: 100% !important; @@ -63,8 +63,8 @@ border-style: hidden; } -.nx-editor-doc .ProseMirror td, -.nx-editor-doc .ProseMirror th { +.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; @@ -73,36 +73,36 @@ position: relative; } -.nx-editor-doc .ProseMirror tr:first-child td, -.nx-editor-doc .ProseMirror tr:first-child th { +.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; } -.nx-editor-doc .ProseMirror td.selectedCell, -.nx-editor-doc .ProseMirror th.selectedCell { +.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%); } -.nx-editor-doc .ProseMirror tr:first-child td.selectedCell, -.nx-editor-doc .ProseMirror tr:first-child th.selectedCell { +.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%)); } -.nx-editor-doc .ProseMirror td > *:first-child, -.nx-editor-doc .ProseMirror th > *:first-child { +.ew-editor-doc .ProseMirror td > *:first-child, +.ew-editor-doc .ProseMirror th > *:first-child { margin-top: 0; } -.nx-editor-doc .ProseMirror td > *:last-child, -.nx-editor-doc .ProseMirror th > *:last-child { +.ew-editor-doc .ProseMirror td > *:last-child, +.ew-editor-doc .ProseMirror th > *:last-child { margin-bottom: 0; } -.nx-editor-doc p code, -.nx-editor-doc pre { +.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; @@ -110,17 +110,17 @@ font-size: 14px; } -.nx-editor-doc .ProseMirror pre { +.ew-editor-doc .ProseMirror pre { white-space: pre-wrap; } -.nx-editor-doc blockquote { +.ew-editor-doc blockquote { position: relative; padding: 0 0.5rem 0 1.5rem; margin: 0; } -.nx-editor-doc blockquote::before { +.ew-editor-doc blockquote::before { position: absolute; display: block; content: ''; @@ -132,15 +132,15 @@ border-radius: 2px; } -.nx-editor-doc .ProseMirror-hideselection *::selection { +.ew-editor-doc .ProseMirror-hideselection *::selection { background: transparent; } -.nx-editor-doc .ProseMirror-selectednode { +.ew-editor-doc .ProseMirror-selectednode { outline: 2px solid var(--s2-blue-800, #1473e6); } -.nx-editor-doc .ProseMirror-gapcursor::after { +.ew-editor-doc .ProseMirror-gapcursor::after { content: ""; display: block; position: absolute; @@ -156,14 +156,14 @@ } } -.nx-editor-doc .ProseMirror .tableWrapper { +.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; } -.nx-editor-doc .ProseMirror .column-resize-handle { +.ew-editor-doc .ProseMirror .column-resize-handle { position: absolute; right: -2px; top: 0; @@ -174,17 +174,17 @@ pointer-events: none; } -.nx-editor-doc .ProseMirror.resize-cursor { +.ew-editor-doc .ProseMirror.resize-cursor { cursor: ew-resize; cursor: col-resize; } -.nx-editor-doc .ProseMirror td > *:has(+ .column-resize-handle), -.nx-editor-doc .ProseMirror th > *:has(+ .column-resize-handle) { +.ew-editor-doc .ProseMirror td > *:has(+ .column-resize-handle), +.ew-editor-doc .ProseMirror th > *:has(+ .column-resize-handle) { margin-bottom: 0; } -.nx-editor-doc .ProseMirror-yjs-cursor { +.ew-editor-doc .ProseMirror-yjs-cursor { position: relative; margin-left: -1px; margin-right: -1px; @@ -195,7 +195,7 @@ pointer-events: none; } -.nx-editor-doc .ProseMirror-yjs-cursor > div { +.ew-editor-doc .ProseMirror-yjs-cursor > div { position: absolute; top: -1.05em; left: -1px; @@ -207,7 +207,7 @@ color: white; } -.nx-editor-doc-placeholder { +.ew-editor-doc-placeholder { display: flex; align-items: center; justify-content: center; @@ -219,13 +219,13 @@ min-height: 200px; } -.nx-editor-doc .nx-editor-doc-placeholder code { +.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; } -.nx-editor-doc-error { +.ew-editor-doc-error { color: light-dark(#c00, #ff6b6b); font-size: 0.875rem; padding: 0.5rem; @@ -233,7 +233,7 @@ /* Ported from da.live prose: table select handle + image focal point */ -.nx-editor-doc .table-select-handle { +.ew-editor-doc .table-select-handle { position: absolute; width: 20px; height: 20px; @@ -251,26 +251,26 @@ justify-content: center; } -.nx-editor-doc .table-select-handle.is-visible { +.ew-editor-doc .table-select-handle.is-visible { display: flex; } -.nx-editor-doc .table-select-handle:hover { +.ew-editor-doc .table-select-handle:hover { background-color: light-dark(#f0f7ff, rgb(20 115 230 / 20%)); border-color: var(--s2-blue-800, #1473e6); } -.nx-editor-doc .focal-point-image-wrapper { +.ew-editor-doc .focal-point-image-wrapper { position: relative; display: block; } -.nx-editor-doc .focal-point-image-wrapper img { +.ew-editor-doc .focal-point-image-wrapper img { display: block; position: relative; } -.nx-editor-doc .focal-point-icon { +.ew-editor-doc .focal-point-icon { position: absolute; bottom: 4px; left: 4px; @@ -289,28 +289,28 @@ pointer-events: auto; } -.nx-editor-doc .focal-point-icon-active { +.ew-editor-doc .focal-point-icon-active { opacity: 1; } -.nx-editor-doc .focal-point-icon:hover { +.ew-editor-doc .focal-point-icon:hover { background: light-dark(#fff, var(--s2-gray-75, #3d3d3d)); border-color: var(--s2-blue-800, #1473e6); } -.nx-editor-doc .focal-point-image-wrapper:hover .focal-point-icon:not(.focal-point-icon-active) { +.ew-editor-doc .focal-point-image-wrapper:hover .focal-point-icon:not(.focal-point-icon-active) { opacity: 1; } -.nx-editor-doc .focal-point-icon svg { +.ew-editor-doc .focal-point-icon svg { color: light-dark(#505050, var(--s2-gray-700, #cacaca)); } -.nx-editor-doc .focal-point-icon svg .fill { +.ew-editor-doc .focal-point-icon svg .fill { fill: currentcolor; } -.nx-editor-doc .focal-point-icon:hover svg { +.ew-editor-doc .focal-point-icon:hover svg { color: var(--s2-blue-800, #1473e6); } diff --git a/blocks/canvas/nx-editor-doc/nx-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js similarity index 94% rename from blocks/canvas/nx-editor-doc/nx-editor-doc.js rename to blocks/canvas/ew-editor-doc/ew-editor-doc.js index eb5994f30..407899b23 100644 --- a/blocks/canvas/nx-editor-doc/nx-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -3,7 +3,7 @@ import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper'; import { getNx } from '../../../scripts/utils.js'; const { loadStyle } = await import(`${getNx()}/utils/utils.js`); import { updateDocument, updateCursors, getInstrumentedHTML, editorHtmlChange, editorSelectChange } from '../editor-utils/document.js'; -import { getActiveBlockFlatIndex, getBlockPositions } from '../nx-editor-wysiwyg/utils/blocks.js'; +import { getActiveBlockFlatIndex, getBlockPositions } from '../ew-editor-wysiwyg/utils/blocks.js'; import { getEditor } from '../editor-utils/state.js'; import { editorDocCanLoad, @@ -27,7 +27,7 @@ import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge. const style = await loadStyle(import.meta.url); -export class NxEditorDoc extends LitElement { +export class EwEditorDoc extends LitElement { static properties = { ctx: { type: Object }, quickEditPort: { type: Object }, @@ -154,7 +154,7 @@ export class NxEditorDoc extends LitElement { _setEditable(editable) { this.requestUpdate(); afterNextPaint(() => { - const pm = this.shadowRoot?.querySelector('.nx-editor-doc-mount .ProseMirror'); + const pm = this.shadowRoot?.querySelector('.ew-editor-doc-mount .ProseMirror'); if (pm) pm.contentEditable = editable ? 'true' : 'false'; }); } @@ -286,8 +286,8 @@ export class NxEditorDoc extends LitElement { }); if (phase === 'incomplete') { return html` -
    -
    +
    +
    Set hash to #/org/site and open an HTML file to edit.
    @@ -295,8 +295,8 @@ export class NxEditorDoc extends LitElement { } if (phase === 'error') { return html` -
    -
    ${this._error}
    +
    +
    ${this._error}
    `; } @@ -304,11 +304,11 @@ export class NxEditorDoc extends LitElement { return nothing; } return html` -
    -
    +
    +
    `; } } -customElements.define('nx-editor-doc', NxEditorDoc); +customElements.define('ew-editor-doc', EwEditorDoc); diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/base64Uploader.js rename to blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/codemark.js b/blocks/canvas/ew-editor-doc/prose-plugins/codemark.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/codemark.js rename to blocks/canvas/ew-editor-doc/prose-plugins/codemark.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/focalPointDialog.js b/blocks/canvas/ew-editor-doc/prose-plugins/focalPointDialog.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/focalPointDialog.js rename to blocks/canvas/ew-editor-doc/prose-plugins/focalPointDialog.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/imageDrop.js rename to blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/imageFocalPoint.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageFocalPoint.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/imageFocalPoint.js rename to blocks/canvas/ew-editor-doc/prose-plugins/imageFocalPoint.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/inlinesvg.js b/blocks/canvas/ew-editor-doc/prose-plugins/inlinesvg.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/inlinesvg.js rename to blocks/canvas/ew-editor-doc/prose-plugins/inlinesvg.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/sectionPasteHandler.js b/blocks/canvas/ew-editor-doc/prose-plugins/sectionPasteHandler.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/sectionPasteHandler.js rename to blocks/canvas/ew-editor-doc/prose-plugins/sectionPasteHandler.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/sourceUploadContext.js rename to blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/tableSelectHandle.js b/blocks/canvas/ew-editor-doc/prose-plugins/tableSelectHandle.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/tableSelectHandle.js rename to blocks/canvas/ew-editor-doc/prose-plugins/tableSelectHandle.js diff --git a/blocks/canvas/nx-editor-doc/prose-plugins/tableUtils.js b/blocks/canvas/ew-editor-doc/prose-plugins/tableUtils.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose-plugins/tableUtils.js rename to blocks/canvas/ew-editor-doc/prose-plugins/tableUtils.js diff --git a/blocks/canvas/nx-editor-doc/prose.js b/blocks/canvas/ew-editor-doc/prose.js similarity index 100% rename from blocks/canvas/nx-editor-doc/prose.js rename to blocks/canvas/ew-editor-doc/prose.js diff --git a/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js similarity index 98% rename from blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js rename to blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js index d84f57a02..a9b66a573 100644 --- a/blocks/canvas/nx-editor-doc/slash-menu/slash-menu.js +++ b/blocks/canvas/ew-editor-doc/slash-menu/slash-menu.js @@ -63,7 +63,7 @@ function setup(container, view) { view.focus(); }); - const scrollEl = container.closest('.nx-editor-doc'); + const scrollEl = container.closest('.ew-editor-doc'); const onScroll = () => { if (menu.open) menu.reposition(); }; scrollEl?.addEventListener('scroll', onScroll, { passive: true }); diff --git a/blocks/canvas/nx-editor-doc/utils/awareness-users.js b/blocks/canvas/ew-editor-doc/utils/awareness-users.js similarity index 100% rename from blocks/canvas/nx-editor-doc/utils/awareness-users.js rename to blocks/canvas/ew-editor-doc/utils/awareness-users.js diff --git a/blocks/canvas/nx-editor-doc/utils/collab.js b/blocks/canvas/ew-editor-doc/utils/collab.js similarity index 100% rename from blocks/canvas/nx-editor-doc/utils/collab.js rename to blocks/canvas/ew-editor-doc/utils/collab.js diff --git a/blocks/canvas/nx-editor-doc/utils/ctx.js b/blocks/canvas/ew-editor-doc/utils/ctx.js similarity index 100% rename from blocks/canvas/nx-editor-doc/utils/ctx.js rename to blocks/canvas/ew-editor-doc/utils/ctx.js diff --git a/blocks/canvas/nx-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js similarity index 100% rename from blocks/canvas/nx-editor-doc/utils/load-editor-doc.js rename to blocks/canvas/ew-editor-doc/utils/load-editor-doc.js diff --git a/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js b/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js similarity index 93% rename from blocks/canvas/nx-editor-doc/utils/quick-edit-host.js rename to blocks/canvas/ew-editor-doc/utils/quick-edit-host.js index a469e8a42..eb55b7198 100644 --- a/blocks/canvas/nx-editor-doc/utils/quick-edit-host.js +++ b/blocks/canvas/ew-editor-doc/utils/quick-edit-host.js @@ -1,4 +1,4 @@ -import { createControllerOnMessage } from '../../nx-editor-wysiwyg/quick-edit-controller.js'; +import { createControllerOnMessage } from '../../ew-editor-wysiwyg/quick-edit-controller.js'; import { getNx } from '../../../../scripts/utils.js'; import { updateDocument, updateCursors } from '../../editor-utils/document.js'; import { fetchWysiwygCookie } from '../../editor-utils/preview.js'; diff --git a/blocks/canvas/nx-editor-doc/utils/shadow-mount.js b/blocks/canvas/ew-editor-doc/utils/shadow-mount.js similarity index 92% rename from blocks/canvas/nx-editor-doc/utils/shadow-mount.js rename to blocks/canvas/ew-editor-doc/utils/shadow-mount.js index 61295617e..59456c6d3 100644 --- a/blocks/canvas/nx-editor-doc/utils/shadow-mount.js +++ b/blocks/canvas/ew-editor-doc/utils/shadow-mount.js @@ -5,7 +5,7 @@ export function afterNextPaint(cb) { export function ensureProseMountedInShadow({ shadowRoot, proseEl, - mountSelector = '.nx-editor-doc-mount', + mountSelector = '.ew-editor-doc-mount', }) { const mount = shadowRoot?.querySelector(mountSelector); if (!mount || mount.contains(proseEl)) return; diff --git a/blocks/canvas/nx-editor-doc/utils/source.js b/blocks/canvas/ew-editor-doc/utils/source.js similarity index 100% rename from blocks/canvas/nx-editor-doc/utils/source.js rename to blocks/canvas/ew-editor-doc/utils/source.js diff --git a/blocks/canvas/nx-editor-doc/utils/teardown.js b/blocks/canvas/ew-editor-doc/utils/teardown.js similarity index 100% rename from blocks/canvas/nx-editor-doc/utils/teardown.js rename to blocks/canvas/ew-editor-doc/utils/teardown.js diff --git a/blocks/canvas/nx-editor-split/nx-editor-split.css b/blocks/canvas/ew-editor-split/ew-editor-split.css similarity index 90% rename from blocks/canvas/nx-editor-split/nx-editor-split.css rename to blocks/canvas/ew-editor-split/ew-editor-split.css index f21bf4b97..1fc9a2aa2 100644 --- a/blocks/canvas/nx-editor-split/nx-editor-split.css +++ b/blocks/canvas/ew-editor-split/ew-editor-split.css @@ -23,7 +23,7 @@ } /* Split: preview left, gutter, doc right; ratio = left / (100% − 2px) */ -.nx-canvas-editor-mount-split nx-editor-wysiwyg { +.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); @@ -34,7 +34,7 @@ max-height: none; } -.nx-canvas-editor-mount-split nx-editor-doc { +.nx-canvas-editor-mount-split ew-editor-doc { flex: 1 1 0; min-width: 0; width: auto; diff --git a/blocks/canvas/nx-editor-split/nx-editor-split.js b/blocks/canvas/ew-editor-split/ew-editor-split.js similarity index 97% rename from blocks/canvas/nx-editor-split/nx-editor-split.js rename to blocks/canvas/ew-editor-split/ew-editor-split.js index 04eabffa0..d91c6e007 100644 --- a/blocks/canvas/nx-editor-split/nx-editor-split.js +++ b/blocks/canvas/ew-editor-split/ew-editor-split.js @@ -65,8 +65,8 @@ function ensureSplitGutter(mountRoot) { /** WYSIWYG (left), 2px gutter, doc (right) — safe if other nodes exist in the mount. */ export function finalizeSplitEditorMountOrder(mountRoot) { - const doc = mountRoot.querySelector('nx-editor-doc'); - const wyg = mountRoot.querySelector('nx-editor-wysiwyg'); + 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); diff --git a/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.css b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css similarity index 79% rename from blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.css rename to blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css index f09d0b7e3..9369524c3 100644 --- a/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.css +++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.css @@ -5,7 +5,7 @@ min-height: 0; } -.nx-editor-wysiwyg-surface { +.ew-editor-wysiwyg-surface { display: flex; flex-direction: column; flex: 1; @@ -13,18 +13,18 @@ min-width: 0; } -.nx-editor-wysiwyg-surface[hidden] { +.ew-editor-wysiwyg-surface[hidden] { display: none !important; } -.nx-editor-wysiwyg-iframe { +.ew-editor-wysiwyg-iframe { flex: 1; width: 100%; min-height: 0; border: 0; } -.nx-editor-wysiwyg-placeholder { +.ew-editor-wysiwyg-placeholder { display: flex; align-items: center; justify-content: center; diff --git a/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js similarity index 91% rename from blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js rename to blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js index f4faebb1b..0c1605bcb 100644 --- a/blocks/canvas/nx-editor-wysiwyg/nx-editor-wysiwyg.js +++ b/blocks/canvas/ew-editor-wysiwyg/ew-editor-wysiwyg.js @@ -28,22 +28,22 @@ async function tryLoadWysiwygPreviewCookies({ org, repo, path, getCurrentCtx }) const token = (await loadIms())?.accessToken?.token; if (!token) { // eslint-disable-next-line no-console - console.warn('[nx-editor-wysiwyg] Preview cookies: no auth token, proceeding without cookies'); + 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('[nx-editor-wysiwyg] Preview cookies failed, proceeding without cookies', e); + console.warn('[ew-editor-wysiwyg] Preview cookies failed, proceeding without cookies', e); }); } } catch (e) { // eslint-disable-next-line no-console - console.warn('[nx-editor-wysiwyg] Preview cookie setup failed, proceeding without cookies', e); + 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 NxEditorWysiwyg extends LitElement { +export class EwEditorWysiwyg extends LitElement { static properties = { ctx: { type: Object }, _cookieReady: { state: true }, @@ -171,7 +171,7 @@ export class NxEditorWysiwyg extends LitElement { } catch (err) { this._disposeQuickEditLocalPort(); // eslint-disable-next-line no-console - console.error('[nx-editor-wysiwyg] Error posting init to iframe', err); + console.error('[ew-editor-wysiwyg] Error posting init to iframe', err); } } @@ -206,10 +206,10 @@ export class NxEditorWysiwyg extends LitElement { let body; if (!hasPath) { body = html` -
    Select an HTML file for WYSIWYG preview.
    +
    Select an HTML file for WYSIWYG preview.
    `; } else if (!this._cookieReady) { - body = html`
    Loading preview…
    `; + body = html`
    Loading preview…
    `; } else { const src = this._iframeSrc; body = html` @@ -217,18 +217,18 @@ export class NxEditorWysiwyg extends LitElement { title="WYSIWYG preview" src="${src}" allow="local-network-access" - class="nx-editor-wysiwyg-iframe" + class="ew-editor-wysiwyg-iframe" @load=${this._onIframeLoad} @blur=${this._onIframeBlur} > `; } return html` -
    +
    ${body}
    `; } } -customElements.define('nx-editor-wysiwyg', NxEditorWysiwyg); +customElements.define('ew-editor-wysiwyg', EwEditorWysiwyg); diff --git a/blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js b/blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js similarity index 100% rename from blocks/canvas/nx-editor-wysiwyg/quick-edit-controller.js rename to blocks/canvas/ew-editor-wysiwyg/quick-edit-controller.js diff --git a/blocks/canvas/nx-editor-wysiwyg/utils/blocks.js b/blocks/canvas/ew-editor-wysiwyg/utils/blocks.js similarity index 100% rename from blocks/canvas/nx-editor-wysiwyg/utils/blocks.js rename to blocks/canvas/ew-editor-wysiwyg/utils/blocks.js diff --git a/blocks/canvas/nx-editor-wysiwyg/utils/handlers.js b/blocks/canvas/ew-editor-wysiwyg/utils/handlers.js similarity index 100% rename from blocks/canvas/nx-editor-wysiwyg/utils/handlers.js rename to blocks/canvas/ew-editor-wysiwyg/utils/handlers.js diff --git a/blocks/canvas/nx-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js similarity index 100% rename from blocks/canvas/nx-editor-wysiwyg/utils/image.js rename to blocks/canvas/ew-editor-wysiwyg/utils/image.js diff --git a/blocks/canvas/nx-page-outline/nx-page-outline.css b/blocks/canvas/ew-page-outline/ew-page-outline.css similarity index 85% rename from blocks/canvas/nx-page-outline/nx-page-outline.css rename to blocks/canvas/ew-page-outline/ew-page-outline.css index d30eef1de..fc7c3a3af 100644 --- a/blocks/canvas/nx-page-outline/nx-page-outline.css +++ b/blocks/canvas/ew-page-outline/ew-page-outline.css @@ -5,7 +5,7 @@ font-family: var(--s2-font-family); } -.nx-page-outline { +.ew-page-outline { display: flex; flex-direction: column; height: 100%; @@ -14,19 +14,19 @@ } -.nx-page-outline-list-wrap { +.ew-page-outline-list-wrap { flex: 1; overflow-y: auto; padding: var(--s2-spacing-75) 0; } -.nx-page-outline-list { +.ew-page-outline-list { list-style: none; margin: 0; padding: 0; } -.nx-page-outline-section { +.ew-page-outline-section { margin: 0; padding: 0; border-bottom: 1px solid var(--s2-gray-200); @@ -36,11 +36,11 @@ } } -.nx-page-outline-section-header { +.ew-page-outline-section-header { background: var(--s2-gray-75); } -.nx-page-outline-section-label { +.ew-page-outline-section-label { display: block; padding: var(--s2-spacing-200) var(--s2-spacing-100); font-size: var(--s2-body-size-s); @@ -48,13 +48,13 @@ color: var(--s2-gray-800); } -.nx-page-outline-block-list { +.ew-page-outline-block-list { list-style: none; margin: 0; padding: 0; } -.nx-page-outline-block { +.ew-page-outline-block { display: block; padding: var(--s2-spacing-75) var(--s2-spacing-100); padding-inline-start: var(--s2-spacing-300); @@ -93,17 +93,17 @@ border-radius: var(--s2-corner-radius-75); } -.nx-page-outline-block-empty { +.ew-page-outline-block-empty { cursor: default; } -.nx-page-outline-empty-label { +.ew-page-outline-empty-label { font-style: italic; color: var(--s2-gray-600); font-weight: 400; } -.nx-page-outline-placeholder { +.ew-page-outline-placeholder { padding: var(--s2-spacing-300) var(--s2-spacing-100); font-size: var(--s2-body-size-s); color: var(--s2-gray-600); diff --git a/blocks/canvas/nx-page-outline/nx-page-outline.js b/blocks/canvas/ew-page-outline/ew-page-outline.js similarity index 84% rename from blocks/canvas/nx-page-outline/nx-page-outline.js rename to blocks/canvas/ew-page-outline/ew-page-outline.js index 1a24cc2b6..3211036a7 100644 --- a/blocks/canvas/nx-page-outline/nx-page-outline.js +++ b/blocks/canvas/ew-page-outline/ew-page-outline.js @@ -31,7 +31,7 @@ function sectionsEqual(a, b) { }); } -class NxPageOutline extends LitElement { +class EwPageOutline extends LitElement { static properties = { _sections: { state: true }, _selectedBlockFlatIndex: { state: true }, @@ -109,19 +109,19 @@ class NxPageOutline extends LitElement { _renderSection(sec, isFirstSection) { return html` -
  • -
    - +
  • +
    +
    -
      ${sec.blocks.length === 0 - ? html`
    • - No blocks + No blocks
    • ` : sec.blocks.map(({ name, blockFlatIndex }, blockIdx) => html` -
    • this._select(blockFlatIndex)}>${name}
    • `)} @@ -131,17 +131,17 @@ class NxPageOutline extends LitElement { render() { if (!this._selectedPath) { - return html`
      -

      Select a page to see its outline.

      + return html`
      +

      Select a page to see its outline.

      `; } return html` -
      -
      +
      +
      ${!this._sections - ? html`

      No blocks found.

      ` - : html`
        No blocks found.

        ` + : html`
          ${this._sections.map((sec, i) => this._renderSection(sec, i === 0))}
        `} @@ -150,4 +150,4 @@ class NxPageOutline extends LitElement { } } -customElements.define('nx-page-outline', NxPageOutline); +customElements.define('ew-page-outline', EwPageOutline); diff --git a/blocks/canvas/nx-panel-extensions/aem-assets.js b/blocks/canvas/ew-panel-extensions/aem-assets.js similarity index 100% rename from blocks/canvas/nx-panel-extensions/aem-assets.js rename to blocks/canvas/ew-panel-extensions/aem-assets.js diff --git a/blocks/canvas/nx-panel-extensions/nx-panel-extensions.css b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.css similarity index 100% rename from blocks/canvas/nx-panel-extensions/nx-panel-extensions.css rename to blocks/canvas/ew-panel-extensions/ew-panel-extensions.css diff --git a/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js similarity index 87% rename from blocks/canvas/nx-panel-extensions/nx-panel-extensions.js rename to blocks/canvas/ew-panel-extensions/ew-panel-extensions.js index 9eaef047d..1d411943f 100644 --- a/blocks/canvas/nx-panel-extensions/nx-panel-extensions.js +++ b/blocks/canvas/ew-panel-extensions/ew-panel-extensions.js @@ -2,11 +2,11 @@ import { LitElement, html, nothing } from 'da-lit'; import { getNx } from '../../../scripts/utils.js'; const { loadStyle, HashController } = await import(`${getNx()}/utils/utils.js`); import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; -import './nx-panel-library.js'; +import './ew-panel-library.js'; const style = await loadStyle(import.meta.url); -class NxPanelExtension extends LitElement { +class EwPanelExtension extends LitElement { static properties = { extension: { attribute: false }, }; @@ -42,7 +42,7 @@ class NxPanelExtension extends LitElement { if (!ext) return nothing; if (ext.ootb) { - return html``; + return html``; } return html`