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/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..7f5742439
--- /dev/null
+++ b/blocks/canvas/ew-version-history/ew-version-history.css
@@ -0,0 +1,241 @@
+: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;
+}
+
+.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
new file mode 100644
index 000000000..b379d2c1c
--- /dev/null
+++ b/blocks/canvas/ew-version-history/ew-version-history.js
@@ -0,0 +1,167 @@
+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(changedProps);
+ }
+
+ // 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.
+
`;
+ }
+ console.log('this._versions', this._versions);
+ 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/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}
-
- ${entry.audits.map((auEntry) => html`
- -
-
${auEntry.time}
-
- ${auEntry.users.map((user) => html`
${user.email}
`)}
-
-
- `)}
-
-
- `;
- }
-
- renderNewVersion() {
- const entry = this._newVersion;
- return html`
-
-
-
-
- `;
- }
-
- 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...
-
- `;
- }
-
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 98%
rename from blocks/edit/da-editor/da-compare.css
rename to blocks/shared/version/compare.css
index 8a0ed7392..17bdc0838 100644
--- a/blocks/edit/da-editor/da-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/compare.js b/blocks/shared/version/compare.js
new file mode 100644
index 000000000..bb95b194f
--- /dev/null
+++ b/blocks/shared/version/compare.js
@@ -0,0 +1,90 @@
+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) => {
+ 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 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');
+ 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..8cb633583
--- /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(changedProps);
+ }
+
+ // --- 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`
+
+
+
+
+ `;
+ }
+
+ 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/shared/version/helpers.test.js
similarity index 97%
rename from test/unit/blocks/edit/da-versions/helpers.test.js
rename to test/unit/blocks/shared/version/helpers.test.js
index 118621c8c..7f7d5adbc 100644
--- a/test/unit/blocks/edit/da-versions/helpers.test.js
+++ b/test/unit/blocks/shared/version/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' };
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', () => {