From 8b28952da85274f96cea9c6dd6ceef3462773172 Mon Sep 17 00:00:00 2001 From: svinod Date: Tue, 2 Jun 2026 17:35:42 +0200 Subject: [PATCH 1/5] refactor: extract version + compare to shared --- blocks/browse/da-list-item/da-list-item.js | 3 +- blocks/edit/da-editor/da-compare.js | 92 ++-------- blocks/edit/da-versions/da-versions.js | 160 +----------------- blocks/edit/prose/diff/generate-diff.js | 2 +- blocks/shared/utils.js | 7 + .../version/compare.css} | 0 blocks/shared/version/compare.js | 72 ++++++++ blocks/shared/version/da-versions-base.js | 160 ++++++++++++++++++ .../da-versions => shared/version}/helpers.js | 7 +- .../prose/diff => shared/version}/htmldiff.js | 0 .../blocks/edit/da-versions/helpers.test.js | 3 +- 11 files changed, 262 insertions(+), 244 deletions(-) rename blocks/{edit/da-editor/da-compare.css => shared/version/compare.css} (100%) create mode 100644 blocks/shared/version/compare.js create mode 100644 blocks/shared/version/da-versions-base.js rename blocks/{edit/da-versions => shared/version}/helpers.js (71%) rename blocks/{edit/prose/diff => shared/version}/htmldiff.js (100%) diff --git a/blocks/browse/da-list-item/da-list-item.js b/blocks/browse/da-list-item/da-list-item.js index 58b7af598..9fc7d58a8 100644 --- a/blocks/browse/da-list-item/da-list-item.js +++ b/blocks/browse/da-list-item/da-list-item.js @@ -1,8 +1,7 @@ import { LitElement, html, nothing, until } from 'da-lit'; -import { delay, sanitizeName } from '../../shared/utils.js'; +import { delay, sanitizeName, formatDate } from '../../shared/utils.js'; import { getNx, getNx2Api } from '../../../scripts/utils.js'; import getEditPath from '../shared.js'; -import { formatDate } from '../../edit/da-versions/helpers.js'; // Styles const { loadStyle } = await import(`${getNx()}/utils/utils.js`); diff --git a/blocks/edit/da-editor/da-compare.js b/blocks/edit/da-editor/da-compare.js index 1586bf003..3f6d93498 100644 --- a/blocks/edit/da-editor/da-compare.js +++ b/blocks/edit/da-editor/da-compare.js @@ -1,97 +1,35 @@ -import { DOMSerializer } from 'da-y-wrapper'; -import { html } from 'da-lit'; import getSheet from '../../shared/sheet.js'; +import { docToHtml, domToHtml, buildCompareDom, renderCompareModal } from '../../shared/version/compare.js'; let compareSheetPromise; function loadCompareSheet() { if (!compareSheetPromise) { - compareSheetPromise = getSheet('/blocks/edit/da-editor/da-compare.css'); + compareSheetPromise = getSheet('/blocks/shared/version/compare.css'); } return compareSheetPromise; } -function wrapTablesInWrappers(root) { - root.querySelectorAll('table').forEach((table) => { - if (table.parentElement?.classList.contains('tableWrapper')) return; - const wrapper = document.createElement('div'); - wrapper.className = 'tableWrapper'; - table.replaceWith(wrapper); - wrapper.appendChild(table); - }); -} - -function stripEmptyTopLevelBlocks(root) { - const isWhitespace = (s) => !s || /^\s*$/.test(s); - Array.from(root.children).forEach((child) => { - const hasMedia = child.querySelector('img, video, table, hr, iframe, svg'); - if (!hasMedia && isWhitespace(child.textContent)) child.remove(); - }); -} - -/** - * @param {object} opts - * @param {ShadowRoot} opts.shadowRoot - * @param {Element|null} opts.versionDom - * @param {Function} opts.onClose - * @param {Function} opts.onResult - called with (compareDom, cleanup) - */ export async function compare({ shadowRoot, versionDom, onClose, onResult }) { - const { schema, doc } = window.view.state; - const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content); - const liveContainer = document.createElement('div'); - liveContainer.append(fragment); - wrapTablesInWrappers(liveContainer); - stripEmptyTopLevelBlocks(liveContainer); - const liveHtml = liveContainer.innerHTML; - - let versionHtml = ''; - if (versionDom) { - const versionContainer = versionDom.cloneNode(true); - stripEmptyTopLevelBlocks(versionContainer); - versionHtml = versionContainer.innerHTML; - } - - const [{ htmlDiff }, compareSheet] = await Promise.all([ - import('../prose/diff/htmldiff.js'), + const [{ dom, cleanup }, compareSheet] = await Promise.all([ + buildCompareDom({ + htmlA: docToHtml(window.view), + htmlB: versionDom ? domToHtml(versionDom) : '', + onClose, + }), loadCompareSheet(), ]); - if (!shadowRoot.adoptedStyleSheets.includes(compareSheet)) { shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, compareSheet]; } - - const dom = document.createElement('div'); - dom.className = 'ProseMirror'; - dom.innerHTML = htmlDiff(liveHtml, versionHtml); - wrapTablesInWrappers(dom); - - const onDocClick = (ev) => { - const insideModal = ev.composedPath().some((n) => n?.classList?.contains?.('da-compare-modal')); - if (!insideModal) onClose(); - }; - - const cleanup = () => document.removeEventListener('click', onDocClick, true); - - setTimeout(() => document.addEventListener('click', onDocClick, true), 0); - onResult(dom, cleanup); } export function renderModal(versionLabel, compareDom, onClose) { - return html` -
- -
`; + return renderCompareModal({ + title: 'Compare with current document', + labelA: 'Current Document', + labelB: `Version: ${versionLabel || ''}`, + compareDom, + onClose, + }); } diff --git a/blocks/edit/da-versions/da-versions.js b/blocks/edit/da-versions/da-versions.js index c508d408c..cf78f1149 100644 --- a/blocks/edit/da-versions/da-versions.js +++ b/blocks/edit/da-versions/da-versions.js @@ -1,169 +1,15 @@ -import { LitElement, html, nothing } from 'da-lit'; +import { html, nothing } from 'da-lit'; import getSheet from '../../shared/sheet.js'; -import { DA_ORIGIN } from '../../shared/constants.js'; -import { formatDate, formatVersions } from './helpers.js'; -import { daFetch } from '../../shared/utils.js'; +import DaVersionsBase from '../../shared/version/da-versions-base.js'; const sheet = await getSheet('/blocks/edit/da-versions/da-versions.css'); -export default class DaVersions extends LitElement { - static properties = { - open: { attribute: false }, - path: { type: String }, - _versions: { state: true }, - _newVersion: { state: true }, - _loading: { state: true }, - }; - +export default class DaVersions extends DaVersionsBase { connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [sheet]; } - async getVersions() { - this._loading = true; - this._versions = null; - const resp = await daFetch(`${DA_ORIGIN}/versionlist${this.path}`); - if (!resp.ok) { - this._loading = false; - return; - } - try { - const json = await resp.json(); - this._versions = formatVersions(json); - } catch { - this._versions = []; - } - this._loading = false; - } - - handleClose() { - const opts = { bubbles: true, composed: true }; - const event = new CustomEvent('close', opts); - this.dispatchEvent(event); - } - - async handlePreview(e, entry) { - e.stopPropagation(); - const entryEl = e.target.closest('.da-version-entry'); - if (!entryEl.classList.contains('is-open')) { - entryEl.classList.toggle('is-open'); - } - const detail = { url: `${DA_ORIGIN}${entry.url}`, label: entry.label, date: entry.date }; - const opts = { detail, bubbles: true, composed: true }; - const event = new CustomEvent('preview', opts); - this.dispatchEvent(event); - } - - handleExpand({ target }) { - target.closest('.da-version-entry').classList.toggle('is-open'); - } - - async handleNewSubmit(e) { - e.preventDefault(); - const entry = { ...this._newVersion }; - if (e.target.elements.label?.value) entry.label = e.target.elements.label.value; - - const opts = { method: 'POST' }; - if (entry.label) opts.body = JSON.stringify({ label: entry.label }); - - const res = await daFetch(`${DA_ORIGIN}/versionsource${this.path}`, opts); - if (res.status !== 201) return; - - this._newVersion = null; - this._versions.unshift(entry); - // TODO: The server does not respond with version details, so get a fresh list - this.getVersions(); - } - - handleNew(e) { - e.target.disabled = true; - const { date, time } = formatDate(); - this._newVersion = { date, time, isVersion: true, users: [] }; - } - - handleCancel() { - this._newVersion = null; - } - - update(changedProps) { - if (changedProps.has('open') && this.open) this.getVersions(); - super.update(); - } - - renderAudits(entry) { - return html` -
  • - -

    ${entry.date}

    - -
  • - `; - } - - renderNewVersion() { - const entry = this._newVersion; - return html` -
  • -
    - -

    ${entry.date}

    - -
    - -
  • - `; - } - - renderVersion(entry) { - return html` -
  • - -

    ${entry.date}

    - ${entry.label ? html`

    ${entry.label}

    ` : nothing} - -
  • - `; - } - - renderNow() { - return html` -
  • - -

    Now

    -
  • - `; - } - - renderVersionList() { - return this._versions.map((entry) => html`${entry.isVersion ? this.renderVersion(entry) : this.renderAudits(entry)}`); - } - - renderLoading() { - return html` -
  • -
    -

    Loading...

    -
  • - `; - } - render() { return html`
    diff --git a/blocks/edit/prose/diff/generate-diff.js b/blocks/edit/prose/diff/generate-diff.js index 455cc197f..a21d508aa 100644 --- a/blocks/edit/prose/diff/generate-diff.js +++ b/blocks/edit/prose/diff/generate-diff.js @@ -1,4 +1,4 @@ -import { htmlDiff } from './htmldiff.js'; +import { htmlDiff } from '../../../shared/version/htmldiff.js'; function fragmentToHTML(fragment) { if (!fragment) return ''; diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index f540d51fd..21a9a38d7 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -302,6 +302,13 @@ export const getAemSiteToken = (() => { }; })(); +export function formatDate(timestamp) { + const rawDate = timestamp ? new Date(timestamp) : new Date(); + const date = rawDate.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); + const time = rawDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + return { date, time }; +} + export function delay(ms) { return new Promise((res) => { setTimeout(res, ms); }); } diff --git a/blocks/edit/da-editor/da-compare.css b/blocks/shared/version/compare.css similarity index 100% rename from blocks/edit/da-editor/da-compare.css rename to blocks/shared/version/compare.css diff --git a/blocks/shared/version/compare.js b/blocks/shared/version/compare.js new file mode 100644 index 000000000..8dd08a3aa --- /dev/null +++ b/blocks/shared/version/compare.js @@ -0,0 +1,72 @@ +import { html } from 'da-lit'; +import { DOMSerializer } from 'da-y-wrapper'; + +export function wrapTablesInWrappers(root) { + root.querySelectorAll('table').forEach((table) => { + if (table.parentElement?.classList.contains('tableWrapper')) return; + const wrapper = document.createElement('div'); + wrapper.className = 'tableWrapper'; + table.replaceWith(wrapper); + wrapper.appendChild(table); + }); +} + +export function stripEmptyTopLevelBlocks(root) { + const isWhitespace = (s) => !s || /^\s*$/.test(s); + Array.from(root.children).forEach((child) => { + const hasMedia = child.querySelector('img, video, table, hr, iframe, svg'); + if (!hasMedia && isWhitespace(child.textContent)) child.remove(); + }); +} + +export function docToHtml(view) { + const { schema, doc } = view.state; + const frag = DOMSerializer.fromSchema(schema).serializeFragment(doc.content); + const el = document.createElement('div'); + el.append(frag); + wrapTablesInWrappers(el); + stripEmptyTopLevelBlocks(el); + return el.innerHTML; +} + +export function domToHtml(el) { + const clone = el.cloneNode(true); + stripEmptyTopLevelBlocks(clone); + return clone.innerHTML; +} + +export async function buildCompareDom({ htmlA, htmlB, onClose }) { + const { htmlDiff } = await import('./htmldiff.js'); + const dom = document.createElement('div'); + dom.className = 'ProseMirror'; + dom.innerHTML = htmlDiff(htmlA, htmlB); + wrapTablesInWrappers(dom); + + const onDocClick = (ev) => { + const insideModal = ev.composedPath().some((n) => n?.classList?.contains?.('da-compare-modal')); + if (!insideModal) onClose(); + }; + const cleanup = () => document.removeEventListener('click', onDocClick, true); + setTimeout(() => document.addEventListener('click', onDocClick, true), 0); + + return { dom, cleanup }; +} + +export function renderCompareModal({ title = 'Compare', labelA, labelB, compareDom, onClose }) { + return html` +
    + +
    `; +} diff --git a/blocks/shared/version/da-versions-base.js b/blocks/shared/version/da-versions-base.js new file mode 100644 index 000000000..1ed7e9287 --- /dev/null +++ b/blocks/shared/version/da-versions-base.js @@ -0,0 +1,160 @@ +import { LitElement, html, nothing } from 'da-lit'; +import { DA_ORIGIN } from '../constants.js'; +import { daFetch, formatDate } from '../utils.js'; +import { formatVersions } from './helpers.js'; + +export default class DaVersionsBase extends LitElement { + static properties = { + open: { attribute: false }, + path: { type: String }, + _versions: { state: true }, + _newVersion: { state: true }, + _loading: { state: true }, + }; + + async getVersions() { + if (!this.path) return; + this._loading = true; + this._versions = null; + const resp = await daFetch(`${DA_ORIGIN}/versionlist${this.path}`); + if (!resp.ok) { + this._loading = false; + return; + } + try { + const json = await resp.json(); + this._versions = formatVersions(json); + } catch { + this._versions = []; + } + this._loading = false; + } + + handleClose() { + const opts = { bubbles: true, composed: true }; + this.dispatchEvent(new CustomEvent('close', opts)); + } + + async handlePreview(e, entry) { + e.stopPropagation(); + const entryEl = e.target.closest('.da-version-entry'); + if (!entryEl.classList.contains('is-open')) { + entryEl.classList.toggle('is-open'); + } + const detail = { url: `${DA_ORIGIN}${entry.url}`, label: entry.label, date: entry.date }; + this.dispatchEvent(new CustomEvent('preview', { detail, bubbles: true, composed: true })); + } + + handleExpand({ target }) { + target.closest('.da-version-entry').classList.toggle('is-open'); + } + + async handleNewSubmit(e) { + e.preventDefault(); + const entry = { ...this._newVersion }; + if (e.target.elements.label?.value) entry.label = e.target.elements.label.value; + + const opts = { method: 'POST' }; + if (entry.label) opts.body = JSON.stringify({ label: entry.label }); + + const res = await daFetch(`${DA_ORIGIN}/versionsource${this.path}`, opts); + if (res.status !== 201) return; + + this._newVersion = null; + this._versions.unshift(entry); + // TODO: The server does not respond with version details, so get a fresh list + this.getVersions(); + } + + handleNew(e) { + e.target.disabled = true; + const { date, time } = formatDate(); + this._newVersion = { date, time, isVersion: true, users: [] }; + } + + handleCancel() { + this._newVersion = null; + } + + update(changedProps) { + if (changedProps.has('open') && this.open) this.getVersions(); + super.update(); + } + + // --- render helpers (available to subclasses) --- + + renderAudits(entry) { + return html` +
  • + +

    ${entry.date}

    +
      + ${entry.audits.map((auEntry) => html` +
    • +

      ${auEntry.time}

      +
      + ${auEntry.users.map((user) => html`

      ${user.email}

      `)} +
      +
    • + `)} +
    +
  • + `; + } + + renderNewVersion() { + const entry = this._newVersion; + return html` +
  • +
    + +

    ${entry.date}

    + +
    + +
  • + `; + } + + renderVersion(entry) { + return html` +
  • + +

    ${entry.date}

    + ${entry.label ? html`

    ${entry.label}

    ` : nothing} +
      +
    • +

      ${entry.time}

      +
      + ${entry.users.map((user) => html`

      ${user.email}

      `)} +
      +
    • +
    +
  • + `; + } + + renderNow() { + return html` +
  • + +

    Now

    +
  • + `; + } + + renderVersionList() { + return this._versions.map((entry) => html` + ${entry.isVersion ? this.renderVersion(entry) : this.renderAudits(entry)} + `); + } + + renderLoading() { + return html` +
  • +
    +

    Loading...

    +
  • + `; + } +} diff --git a/blocks/edit/da-versions/helpers.js b/blocks/shared/version/helpers.js similarity index 71% rename from blocks/edit/da-versions/helpers.js rename to blocks/shared/version/helpers.js index 560f31459..3259977d7 100644 --- a/blocks/edit/da-versions/helpers.js +++ b/blocks/shared/version/helpers.js @@ -1,9 +1,4 @@ -export function formatDate(timestamp) { - const rawDate = timestamp ? new Date(timestamp) : new Date(); - const date = rawDate.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); - const time = rawDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); - return { date, time }; -} +import { formatDate } from '../utils.js'; export function formatVersions(json) { // Sort by timestamp epoch diff --git a/blocks/edit/prose/diff/htmldiff.js b/blocks/shared/version/htmldiff.js similarity index 100% rename from blocks/edit/prose/diff/htmldiff.js rename to blocks/shared/version/htmldiff.js diff --git a/test/unit/blocks/edit/da-versions/helpers.test.js b/test/unit/blocks/edit/da-versions/helpers.test.js index 118621c8c..7f7d5adbc 100644 --- a/test/unit/blocks/edit/da-versions/helpers.test.js +++ b/test/unit/blocks/edit/da-versions/helpers.test.js @@ -1,5 +1,6 @@ import { expect } from '@esm-bundle/chai'; -import { formatDate, formatVersions } from '../../../../../blocks/edit/da-versions/helpers.js'; +import { formatDate } from '../../../../../blocks/shared/utils.js'; +import { formatVersions } from '../../../../../blocks/shared/version/helpers.js'; const TIME_OPTS = { hour: 'numeric', minute: '2-digit' }; const DATE_OPTS = { year: 'numeric', month: 'short', day: 'numeric' }; From 5c20bf46ac62c634df2a87d53fc4bf4e8da95819 Mon Sep 17 00:00:00 2001 From: svinod Date: Tue, 2 Jun 2026 17:51:15 +0200 Subject: [PATCH 2/5] chore: fix linting --- .../blocks/{edit/prose/diff => shared/version}/htmldiff.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/unit/blocks/{edit/prose/diff => shared/version}/htmldiff.test.js (99%) diff --git a/test/unit/blocks/edit/prose/diff/htmldiff.test.js b/test/unit/blocks/shared/version/htmldiff.test.js similarity index 99% rename from test/unit/blocks/edit/prose/diff/htmldiff.test.js rename to test/unit/blocks/shared/version/htmldiff.test.js index 0114ef78b..3ee81f96b 100644 --- a/test/unit/blocks/edit/prose/diff/htmldiff.test.js +++ b/test/unit/blocks/shared/version/htmldiff.test.js @@ -1,5 +1,5 @@ import { expect } from '@esm-bundle/chai'; -import { htmlDiff } from '../../../../../../blocks/edit/prose/diff/htmldiff.js'; +import { htmlDiff } from '../../../../../blocks/shared/version/htmldiff.js'; describe('HTML Diff', () => { describe('htmlDiff main function', () => { From b19eebd72c5fd1bea24b7060f578de59b5814ee3 Mon Sep 17 00:00:00 2001 From: svinod Date: Tue, 2 Jun 2026 17:52:45 +0200 Subject: [PATCH 3/5] chore: code cleanup --- .../blocks/{edit/da-versions => shared/version}/helpers.test.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/unit/blocks/{edit/da-versions => shared/version}/helpers.test.js (100%) diff --git a/test/unit/blocks/edit/da-versions/helpers.test.js b/test/unit/blocks/shared/version/helpers.test.js similarity index 100% rename from test/unit/blocks/edit/da-versions/helpers.test.js rename to test/unit/blocks/shared/version/helpers.test.js From 6d0e7cffb1484648335417ab71c38666594e4d23 Mon Sep 17 00:00:00 2001 From: svinod Date: Wed, 3 Jun 2026 08:51:37 +0200 Subject: [PATCH 4/5] feat:version history panel --- blocks/canvas/editor-utils/editor-utils.js | 12 + blocks/canvas/ew-editor-doc/ew-editor-doc.js | 17 +- blocks/canvas/ew-panel-extensions/helpers.js | 14 ++ .../ew-version-history/ew-version-history.css | 222 ++++++++++++++++++ .../ew-version-history/ew-version-history.js | 166 +++++++++++++ blocks/shared/version/compare.js | 18 ++ 6 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 blocks/canvas/ew-version-history/ew-version-history.css create mode 100644 blocks/canvas/ew-version-history/ew-version-history.js diff --git a/blocks/canvas/editor-utils/editor-utils.js b/blocks/canvas/editor-utils/editor-utils.js index e433d4df5..f2baa731f 100644 --- a/blocks/canvas/editor-utils/editor-utils.js +++ b/blocks/canvas/editor-utils/editor-utils.js @@ -294,6 +294,18 @@ export const editorSelectChange = (() => { }; })(); +// Event observable — no replay. Emits { url, label, date } when a version restore is requested. +export const versionPreviewChange = (() => { + 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); diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index 44b9fdd01..fe02706ae 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -1,9 +1,9 @@ import { LitElement, html, nothing } from 'da-lit'; -import { yUndo, yRedo, NodeSelection } from 'da-y-wrapper'; +import { yUndo, yRedo, NodeSelection, DOMParser as proseDOMParser } from 'da-y-wrapper'; import { getNx } from '../../../scripts/utils.js'; import { updateDocument, updateCursors, getInstrumentedHTML, - editorHtmlChange, editorSelectChange, getEditor, + editorHtmlChange, editorSelectChange, getEditor, versionPreviewChange, } from '../editor-utils/editor-utils.js'; import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js'; import { @@ -18,6 +18,7 @@ import { wireQuickEditControllerPort, } from './utils/quick-edit-host.js'; import { initIms as loadIms } from '../../shared/utils.js'; +import { fetchVersionDom } from '../../shared/version/compare.js'; import initProse from './prose.js'; import { createTrackingPlugin } from '../editor-utils/prose-diff.js'; import { resolveEditorDocSession } from './utils/load-editor-doc.js'; @@ -120,6 +121,15 @@ export class EwEditorDoc extends LitElement { if (view) yRedo(view.state, view.dispatch); } + async _restoreVersion({ url }) { + const { view } = this._proseContext ?? {}; + if (!view) return; + const dom = await fetchVersionDom(url); + if (!dom) return; + const newDoc = proseDOMParser.fromSchema(view.state.schema).parse(dom); + view.dispatch(view.state.tr.replaceWith(0, view.state.doc.content.size, newDoc.content)); + } + _setupController() { const { view, wsProvider } = this._proseContext ?? {}; if (!this.quickEditPort || !view || !wsProvider) return; @@ -256,12 +266,15 @@ export class EwEditorDoc extends LitElement { .subscribe(({ blockIndex, source }) => { if (source !== 'doc') this._scrollDocToBlock(blockIndex); }); + this._unsubscribeVersionPreview = versionPreviewChange + .subscribe((detail) => this._restoreVersion(detail)); } disconnectedCallback() { this.parentElement?.removeEventListener('nx-canvas-editor-active', this._onCanvasEditorActive); this.parentElement?.removeEventListener('nx-wysiwyg-port-ready', this._onWysiwygPortReady); this._unsubscribeSelect?.(); + this._unsubscribeVersionPreview?.(); this._teardown(); super.disconnectedCallback(); } diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js index fa26e040b..17267e160 100644 --- a/blocks/canvas/ew-panel-extensions/helpers.js +++ b/blocks/canvas/ew-panel-extensions/helpers.js @@ -409,6 +409,19 @@ function createFileExplorerView() { }; } +function createVersionsView() { + return { + id: 'versions', + label: 'History', + section: 'Editor', + firstParty: true, + load: async () => { + await import('../ew-version-history/ew-version-history.js'); + return document.createElement('ew-version-history'); + }, + }; +} + function extensionToPanelView(ext, section) { const view = { id: ext.name, @@ -475,6 +488,7 @@ export async function getCanvasToolPanelViews({ org, site }) { return [ createOutlineView(), createFileExplorerView(), + createVersionsView(), ...library.map((ext) => extensionToPanelView(ext, 'Library')), ...thirdParty.map((ext) => extensionToPanelView(ext, 'Extensions')), ]; diff --git a/blocks/canvas/ew-version-history/ew-version-history.css b/blocks/canvas/ew-version-history/ew-version-history.css new file mode 100644 index 000000000..27ef0b057 --- /dev/null +++ b/blocks/canvas/ew-version-history/ew-version-history.css @@ -0,0 +1,222 @@ +:host { + display: block; + height: 100%; + min-height: 0; + font-family: var(--s2-font-family); +} + +.ew-version-history { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.list-wrap { + flex: 1; + overflow-y: auto; + padding: var(--s2-spacing-75) 0; +} + +.version-list { + list-style: none; + margin: 0; + padding: 0; +} + +/* ── Create row ─────────────────────────────────── */ + +.version-create { + padding: var(--s2-spacing-75) var(--s2-spacing-100); + border-bottom: 1px solid var(--s2-gray-200); + + button { + background: none; + border: none; + cursor: pointer; + color: var(--s2-blue-700); + font-family: inherit; + font-size: var(--s2-body-size-s); + font-weight: 600; + padding: 0; + + &:hover { text-decoration: underline; } + } +} + +/* ── New version form ───────────────────────────── */ + +.version-new { + padding: var(--s2-spacing-75) var(--s2-spacing-100); + border-bottom: 1px solid var(--s2-gray-200); +} + +.label-input { + display: block; + width: 100%; + box-sizing: border-box; + padding: var(--s2-spacing-75) var(--s2-spacing-100); + border: 1px solid var(--s2-gray-300); + border-radius: var(--s2-corner-radius-75); + font-family: inherit; + font-size: var(--s2-body-size-s); + margin-bottom: var(--s2-spacing-75); + + &:focus { + outline: 2px solid var(--s2-blue-700); + outline-offset: -1px; + border-color: transparent; + } +} + +.form-actions { + display: flex; + gap: var(--s2-spacing-75); +} + +.btn-save, +.btn-cancel { + border-radius: var(--s2-corner-radius-75); + padding: var(--s2-spacing-75) var(--s2-spacing-100); + cursor: pointer; + font-family: inherit; + font-size: var(--s2-body-size-s); + border: 1px solid var(--s2-gray-300); + background: none; +} + +.btn-save { + background: var(--s2-blue-700); + color: #fff; + border-color: var(--s2-blue-700); + + &:hover { background: var(--s2-blue-800, #0d66d0); } +} + +.btn-cancel:hover { background: var(--s2-gray-75); } + +/* ── Version item ───────────────────────────────── */ + +.version-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--s2-spacing-100); + padding: var(--s2-spacing-100); + border-bottom: 1px solid var(--s2-gray-100); + + &:hover { background: var(--s2-gray-75); } + &:last-child { border-bottom: none; } +} + +.version-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.version-date { + font-size: var(--s2-body-size-s); + font-weight: 600; + color: var(--s2-gray-800); +} + +.version-label { + font-size: var(--s2-body-size-xs, 11px); + font-weight: 500; + color: var(--s2-blue-700); +} + +.version-secondary { + font-size: var(--s2-body-size-xs, 11px); + color: var(--s2-gray-600); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-actions { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +.compare-btn, +.restore-btn { + background: none; + border: 1px solid var(--s2-gray-300); + border-radius: var(--s2-corner-radius-75); + padding: 2px var(--s2-spacing-75); + cursor: pointer; + font-family: inherit; + font-size: var(--s2-body-size-xs, 11px); + color: var(--s2-gray-800); + white-space: nowrap; +} + +.restore-btn:hover { + border-color: var(--s2-blue-700); + color: var(--s2-blue-700); +} + +.compare-btn:hover { + border-color: var(--s2-gray-600); +} + +/* ── Audit group ────────────────────────────────── */ + +.audit-group { + padding: var(--s2-spacing-75) var(--s2-spacing-100); + border-bottom: 1px solid var(--s2-gray-100); + + &:last-child { border-bottom: none; } +} + +.audit-date { + display: block; + font-size: var(--s2-body-size-xs, 11px); + font-weight: 600; + color: var(--s2-gray-600); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.audit-list { + list-style: none; + margin: 0; + padding: 0 0 0 var(--s2-spacing-75); +} + +.audit-entry { + display: flex; + gap: var(--s2-spacing-75); + padding: 2px 0; +} + +.audit-time { + flex-shrink: 0; + font-size: var(--s2-body-size-xs, 11px); + color: var(--s2-gray-600); +} + +.audit-users { + font-size: var(--s2-body-size-xs, 11px); + color: var(--s2-gray-700); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── States ─────────────────────────────────────── */ + +.status-msg, +.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; + list-style: none; +} diff --git a/blocks/canvas/ew-version-history/ew-version-history.js b/blocks/canvas/ew-version-history/ew-version-history.js new file mode 100644 index 000000000..c7ad0fe0e --- /dev/null +++ b/blocks/canvas/ew-version-history/ew-version-history.js @@ -0,0 +1,166 @@ +import { html, nothing } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { DA_ORIGIN } from '../../shared/constants.js'; +import getSheet from '../../shared/sheet.js'; +import DaVersionsBase from '../../shared/version/da-versions-base.js'; +import { docToHtml, fetchVersionDom, buildCompareDom, renderCompareModal } from '../../shared/version/compare.js'; +import { versionPreviewChange } from '../editor-utils/editor-utils.js'; +import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; + +const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`); +const style = await loadStyle(import.meta.url); + +let compareSheetPromise; +function loadCompareSheet() { + compareSheetPromise ??= getSheet('/blocks/shared/version/compare.css'); + return compareSheetPromise; +} + +class EwVersionHistory extends DaVersionsBase { + static properties = { + _compareDom: { state: true }, + _compareLabel: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [style]; + this._unsubHash = hashChange.subscribe((state) => { + const { org, site, path } = state ?? {}; + this.path = org && site && path ? `/${org}/${site}/${path}.html` : null; + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._unsubHash?.(); + } + + // Trigger fetch when path changes (panel never uses the open prop) + update(changedProps) { + if (changedProps.has('path') && this.path) this.getVersions(); + super.update(); + } + + // Override: emit to signal instead of bubbling a DOM event + handlePreview(e, entry) { + e.stopPropagation(); + versionPreviewChange.emit({ url: `${DA_ORIGIN}${entry.url}`, label: entry.label, date: entry.date }); + } + + async handleCompare(e, entry) { + e.stopPropagation(); + const { view } = getExtensionsBridge(); + if (!view) return; + + const [versionEl, compareSheet] = await Promise.all([ + fetchVersionDom(`${DA_ORIGIN}${entry.url}`), + loadCompareSheet(), + ]); + if (!versionEl) return; + + if (!this.shadowRoot.adoptedStyleSheets.includes(compareSheet)) { + this.shadowRoot.adoptedStyleSheets = [...this.shadowRoot.adoptedStyleSheets, compareSheet]; + } + + this.handleCloseCompare(); + const { dom, cleanup } = await buildCompareDom({ + htmlA: docToHtml(view), + htmlB: versionEl.innerHTML, + onClose: () => this.handleCloseCompare(), + }); + this._compareDom = dom; + this._compareCleanup = cleanup; + this._compareLabel = entry.label || entry.date || 'Version'; + } + + handleCloseCompare() { + this._compareCleanup?.(); + this._compareCleanup = null; + this._compareDom = null; + this._compareLabel = null; + } + + renderVersionItem(entry) { + const users = entry.users?.map((u) => u.email).join(', '); + return html` +
  • +
    + ${entry.date} + ${entry.label ? html`${entry.label}` : nothing} + ${entry.time}${users ? html` · ${users}` : nothing} +
    +
    + + +
    +
  • + `; + } + + renderAuditGroup(entry) { + return html` +
  • + ${entry.date} +
      + ${entry.audits.map((a) => html` +
    • + ${a.time} + ${a.users.map((u) => u.email).join(', ')} +
    • + `)} +
    +
  • + `; + } + + renderCreateRow() { + if (this._newVersion) { + return html` +
  • +
    + +
    + + +
    +
    +
  • + `; + } + return html` +
  • + +
  • + `; + } + + render() { + if (!this.path) { + return html`
    +

    Select a page to see its history.

    +
    `; + } + return html` +
    +
    +
      + ${this.renderCreateRow()} + ${this._loading ? html`
    • Loading…
    • ` : nothing} + ${this._versions?.map((entry) => (entry.isVersion + ? this.renderVersionItem(entry) + : this.renderAuditGroup(entry)))} +
    +
    + ${this._compareDom ? renderCompareModal({ + labelA: 'Current document', + labelB: this._compareLabel, + compareDom: this._compareDom, + onClose: () => this.handleCloseCompare(), + }) : nothing} +
    + `; + } +} + +customElements.define('ew-version-history', EwVersionHistory); diff --git a/blocks/shared/version/compare.js b/blocks/shared/version/compare.js index 8dd08a3aa..bb95b194f 100644 --- a/blocks/shared/version/compare.js +++ b/blocks/shared/version/compare.js @@ -1,5 +1,6 @@ import { html } from 'da-lit'; import { DOMSerializer } from 'da-y-wrapper'; +import { daFetch } from '../utils.js'; export function wrapTablesInWrappers(root) { root.querySelectorAll('table').forEach((table) => { @@ -35,6 +36,23 @@ export function domToHtml(el) { return clone.innerHTML; } +export async function fetchVersionDom(url) { + const resp = await daFetch(url); + if (!resp.ok) return null; + const rawHtml = await resp.text(); + const { Y } = await import('da-y-wrapper'); + const { aem2doc, getSchema, yDocToProsemirror } = await import('da-parser'); + const ydoc = new Y.Doc(); + aem2doc(rawHtml, ydoc); + const schema = getSchema(); + const pmDoc = yDocToProsemirror(schema, ydoc); + const frag = DOMSerializer.fromSchema(schema).serializeFragment(pmDoc.content); + const el = document.createElement('div'); + el.append(frag); + stripEmptyTopLevelBlocks(el); + return el; +} + export async function buildCompareDom({ htmlA, htmlB, onClose }) { const { htmlDiff } = await import('./htmldiff.js'); const dom = document.createElement('div'); From f051f19b8499fee56d73076617e4d6d3c8aa2ee1 Mon Sep 17 00:00:00 2001 From: svinod Date: Wed, 3 Jun 2026 09:23:25 +0200 Subject: [PATCH 5/5] fix: compare styles --- .../ew-version-history/ew-version-history.css | 31 +++++++++++++++---- .../ew-version-history/ew-version-history.js | 3 +- blocks/shared/version/compare.css | 4 +-- blocks/shared/version/da-versions-base.js | 2 +- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/blocks/canvas/ew-version-history/ew-version-history.css b/blocks/canvas/ew-version-history/ew-version-history.css index 27ef0b057..7f5742439 100644 --- a/blocks/canvas/ew-version-history/ew-version-history.css +++ b/blocks/canvas/ew-version-history/ew-version-history.css @@ -40,7 +40,9 @@ font-weight: 600; padding: 0; - &:hover { text-decoration: underline; } + &:hover { + text-decoration: underline; + } } } @@ -90,10 +92,14 @@ color: #fff; border-color: var(--s2-blue-700); - &:hover { background: var(--s2-blue-800, #0d66d0); } + &:hover { + background: var(--s2-blue-800, #0d66d0); + } } -.btn-cancel:hover { background: var(--s2-gray-75); } +.btn-cancel:hover { + background: var(--s2-gray-75); +} /* ── Version item ───────────────────────────────── */ @@ -105,8 +111,12 @@ padding: var(--s2-spacing-100); border-bottom: 1px solid var(--s2-gray-100); - &:hover { background: var(--s2-gray-75); } - &:last-child { border-bottom: none; } + &:hover { + background: var(--s2-gray-75); + } + &:last-child { + border-bottom: none; + } } .version-meta { @@ -171,7 +181,9 @@ padding: var(--s2-spacing-75) var(--s2-spacing-100); border-bottom: 1px solid var(--s2-gray-100); - &:last-child { border-bottom: none; } + &:last-child { + border-bottom: none; + } } .audit-date { @@ -220,3 +232,10 @@ text-align: center; list-style: none; } + +.da-compare-modal { + position: fixed; + left: 2.5%; + max-width: 95%; + right: 2.5%; +} diff --git a/blocks/canvas/ew-version-history/ew-version-history.js b/blocks/canvas/ew-version-history/ew-version-history.js index c7ad0fe0e..b379d2c1c 100644 --- a/blocks/canvas/ew-version-history/ew-version-history.js +++ b/blocks/canvas/ew-version-history/ew-version-history.js @@ -39,7 +39,7 @@ class EwVersionHistory extends DaVersionsBase { // Trigger fetch when path changes (panel never uses the open prop) update(changedProps) { if (changedProps.has('path') && this.path) this.getVersions(); - super.update(); + super.update(changedProps); } // Override: emit to signal instead of bubbling a DOM event @@ -141,6 +141,7 @@ class EwVersionHistory extends DaVersionsBase {

    Select a page to see its history.

    `; } + console.log('this._versions', this._versions); return html`
    diff --git a/blocks/shared/version/compare.css b/blocks/shared/version/compare.css index 8a0ed7392..17bdc0838 100644 --- a/blocks/shared/version/compare.css +++ b/blocks/shared/version/compare.css @@ -9,7 +9,7 @@ } .da-compare-modal { - background: #FFF; + background: #fff; border-radius: 10px; box-shadow: rgb(0 0 0 / 25%) 0 4px 24px; width: min(960px, 100%); @@ -25,7 +25,7 @@ justify-content: space-between; gap: 16px; padding: 16px 20px; - border-bottom: 1px solid #DDD; + border-bottom: 1px solid #ddd; flex: 0 0 auto; } diff --git a/blocks/shared/version/da-versions-base.js b/blocks/shared/version/da-versions-base.js index 1ed7e9287..8cb633583 100644 --- a/blocks/shared/version/da-versions-base.js +++ b/blocks/shared/version/da-versions-base.js @@ -78,7 +78,7 @@ export default class DaVersionsBase extends LitElement { update(changedProps) { if (changedProps.has('open') && this.open) this.getVersions(); - super.update(); + super.update(changedProps); } // --- render helpers (available to subclasses) ---