Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dc47d59
test(canvas): cover store routing for the source url, the doc check a…
benpeter Aug 15, 2026
95fa1fb
fix(canvas): route the document and the collab room to the site's store
benpeter Aug 15, 2026
6536d15
test(canvas): a store failure should not be reported as a permission …
benpeter Aug 15, 2026
cf09f96
fix(canvas): report the store's status instead of a blanket not-permi…
benpeter Aug 15, 2026
a292029
test(canvas): media bus images render from the preview origin
benpeter Aug 15, 2026
7b68d91
fix(canvas): render media bus images from the preview origin
benpeter Aug 15, 2026
79d4c04
test(canvas): image uploads go to the site's store, in the doc pane a…
benpeter Aug 15, 2026
b9ec78d
fix(canvas): upload images through the source api, into the site's store
benpeter Aug 15, 2026
5b7053a
fix(canvas): check the sign-in before the store, and read da-admin wi…
benpeter Aug 15, 2026
94601ea
test(canvas): media images render from the origin the canvas logged into
benpeter Aug 15, 2026
57e8f95
fix(canvas): render media images from getPreviewOrigin, not getLivePr…
benpeter Aug 15, 2026
605a7fe
fix(canvas): remove leftover debug log from handleImageReplace
mhaack Aug 17, 2026
0b6b765
fix(shared): route fetchDaConfigs through getNx2Api's config API
mhaack Aug 17, 2026
85aaa38
Merge branch 'main' into ewhlx6
mhaack Aug 17, 2026
2e70997
fix(canvas): route getPreviewStatus through getNx2Api's status.get
mhaack Aug 17, 2026
2897858
test: refuse an image over the upload limit
benpeter Aug 18, 2026
cd558cc
fix(canvas): fail an oversized image upload with a toast
benpeter Aug 18, 2026
5f44f11
fix(canvas): apply the image size limit only on the source bus
benpeter Aug 18, 2026
8ea1ff1
refactor(canvas): call buildSourceUrl directly, drop the ctx wrapper
benpeter Aug 18, 2026
cf312ff
fix(canvas): one line of toast text, and the exported error variant
benpeter Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions blocks/canvas/canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
removeSplitGutter,
} from './ew-editor-split/ew-editor-split.js';
import { resolveEditorDocSession } from './ew-editor-doc/utils/load-editor-doc.js';
import { sourceUrlFromEditorCtx } from './ew-editor-doc/utils/ctx.js';
import { SEL_BLOCK, SEL_ITEM, SEL_TEXT } from './ew-editor-doc/utils/selection.js';
import { getChatPanelContent } from '../shared/chat-panel.js';
import { canvasBus } from './utils/canvas-bus.js';
Expand Down Expand Up @@ -108,7 +107,7 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) {
return;
}
const ctx = editorCtxFromHashState(state, fullPath);
const session = await resolveEditorDocSession(sourceUrlFromEditorCtx(ctx));
const session = await resolveEditorDocSession(ctx);
if (loadCount !== editorLoadCount) return;
if (!session.ok) {
removeCanvasEditors(mountRoot);
Expand Down
8 changes: 4 additions & 4 deletions blocks/canvas/ew-editor-doc/ew-editor-doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { updateDocument, updateCursors, getInstrumentedHTML, getEditor } from '.
import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js';
import {
editorDocCanLoad,
sourceUrlFromEditorCtx,
controllerPathnameFromEditorCtx,
editorDocRenderPhase,
} from './utils/ctx.js';
Expand All @@ -24,6 +23,7 @@ import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount
import { teardownEditorDocResources } from './utils/teardown.js';
import { hideSelectionToolbar, setSelectionToolbarCtx } from '../editor-utils/selection-toolbar.js';
import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js';
import mediaBusImage from './prose-plugins/mediaBusImage.js';
import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js';
import { canvasBus } from '../utils/canvas-bus.js';

Expand Down Expand Up @@ -265,13 +265,12 @@ export class EwEditorDoc extends LitElement {
return;
}

const sourceUrl = sourceUrlFromEditorCtx(this.ctx);

const session = this.session ?? await resolveEditorDocSession(sourceUrl);
const session = this.session ?? await resolveEditorDocSession(this.ctx);
if (!session.ok) {
this._error = session.error;
return;
}
const { sourceUrl } = session;

try {
const { token, permissions } = session;
Expand All @@ -282,6 +281,7 @@ export class EwEditorDoc extends LitElement {
setEditable: (editable) => this._setEditable(editable),
getToken: () => token,
extraPlugins: [
mediaBusImage(this.ctx),
createExtensionsBridgePlugin(),
createTrackingPlugin(
() => {
Expand Down
70 changes: 40 additions & 30 deletions blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Plugin } from 'da-y-wrapper';
import { getNx } from '../../../../scripts/utils.js';
import { daFetch } from '../../../shared/utils.js';
import { getNx2Api } from '../../../../scripts/utils.js';
import { getSourceUploadContext } from './sourceUploadContext.js';

const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`);
import { dataUrlByteLength, refuseOversizedImage } from '../../utils/image-upload.js';

const FPO_IMG_URL = '/blocks/edit/img/fpo.svg';

Expand All @@ -14,6 +12,42 @@ function makeHash(string) {
), 0));
}

// the media bus is content addressed, so the src is only known from the response
function removeFpo(view, fpoSrc) {
view.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs.src === fpoSrc) {
view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize));
return false;
}
return true;
});
}

export async function uploadBase64Image(view, { src, path, fpoSrc, parent }) {
if (await refuseOversizedImage(dataUrlByteLength(src), parent)) {
removeFpo(view, fpoSrc);
return;
}
const resp = await fetch(src);
const blob = await resp.blob();
const { source } = await getNx2Api();
const uploadResp = await source.uploadMedia(path, { body: blob });
if (!uploadResp.ok) {
// eslint-disable-next-line no-console
console.error(`Failed to upload pasted image: ${uploadResp.status} ${uploadResp.statusText}`);
return;
}
const { source: { contentUrl } } = await uploadResp.json();

view.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs.src === fpoSrc) {
view.dispatch(view.state.tr.setNodeMarkup(pos, null, { ...node.attrs, src: contentUrl }));
return false;
}
return true;
});
}

/**
* @param {{
* getSourceUrl: () => string | null,
Expand All @@ -35,40 +69,16 @@ export default function base64Uploader({ getSourceUrl, getEditorView }) {
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}`;
const fpoSrc = `${FPO_IMG_URL}#${makeHash(src)}`;
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);
if (view) uploadBase64Image(view, { src, path, fpoSrc, parent: details.parent });
});

const serializer = new XMLSerializer();
Expand Down
32 changes: 21 additions & 11 deletions blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,44 @@
import { Plugin, TextSelection } from 'da-y-wrapper';
import { daFetch } from '../../../shared/utils.js';
import { getNx2Api } from '../../../../scripts/utils.js';
import { getSourceUploadContext } from './sourceUploadContext.js';
import { refuseOversizedImage } from '../../utils/image-upload.js';

const FPO_IMG_URL = '/blocks/edit/img/fpo.svg';
export const SUPPORTED_IMAGE_FILES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif'];

export async function uploadImageFile(view, file, details) {
if (!SUPPORTED_IMAGE_FILES.some((type) => type === file.type)) return;
if (await refuseOversizedImage(file.size, details.parent)) return;

const { schema } = view.state;
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 path = `${details.parent}/.${details.name}/${file.name}`;

const formData = new FormData();
formData.append('data', file);
const resp = await daFetch(url, { method: 'PUT', body: formData });
// the media bus is content addressed, so the src is only known from the response
const { source } = await getNx2Api();
const resp = await source.uploadMedia(path, { body: file });
if (!resp.ok) return;
const json = await resp.json();
const { source: { contentUrl } } = await resp.json();

const docImg = document.createElement('img');
docImg.addEventListener('load', () => {
const replaceFpo = () => {
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 img = schema.nodes.image.create({ src: contentUrl });
view.dispatch(ts.replaceSelectionWith(img).scrollIntoView());
});
docImg.src = json.source.contentUrl;
};

// a media bus src is relative to the published page and cannot load from here
if (contentUrl.startsWith('./media_')) {
replaceFpo();
return;
}

const docImg = document.createElement('img');
docImg.addEventListener('load', replaceFpo);
docImg.src = contentUrl;
}

/**
Expand Down
36 changes: 36 additions & 0 deletions blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// eslint-disable-next-line import/no-unresolved
import { Plugin, PluginKey } from 'da-y-wrapper';
import { getPreviewOrigin } from '../../editor-utils/editor-utils.js';

const mediaBusImageKey = new PluginKey('canvasMediaBusImage');

// a media bus src ("./media_123.png") only resolves where the page is served, so it is rewritten
// to the doc's preview origin for display; node attrs and the saved document keep the relative
// path. getPreviewOrigin matches the origin fetchWysiwygCookie logs into, unlike getLivePreviewUrl.
export function getRenderableSrc(src, ctx) {
if (!src || !src.startsWith('./media_')) return null;
const { org, repo } = ctx ?? {};
if (!org || !repo) return null;
return `${getPreviewOrigin(org, repo)}/${src.slice(2)}`;
}

function updateImageSrcs(view, ctx) {
view.dom.querySelectorAll('img[src^="./media_"]').forEach((img) => {
const renderableSrc = getRenderableSrc(img.getAttribute('src'), ctx);
if (renderableSrc) img.src = renderableSrc;
});
}

export default function mediaBusImage(ctx) {
return new Plugin({
key: mediaBusImageKey,
view(view) {
updateImageSrcs(view, ctx);
return {
update(updatedView, prevState) {
if (updatedView.state.doc !== prevState.doc) updateImageSrcs(updatedView, ctx);
},
};
},
});
}
35 changes: 18 additions & 17 deletions blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails).
* Derives the upload parent and name from a source document URL, in either store's shape.
*/
import { getNx } from '../../../../scripts/utils.js';

const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`);
// da-admin: https://admin.da.live/source/{org}/{site}/dir/doc.html
// source bus: https://api.aem.live/{org}/sites/{site}/source/dir/doc.html
function orgSiteAndRest(pathname) {
const segments = pathname.split('/').filter(Boolean);
if (segments[0] === 'source') return segments.slice(1);
if (segments[1] === 'sites' && segments[3] === 'source') {
return [segments[0], segments[2], ...segments.slice(4)];
}
return null;
}

/**
* @param {string} sourceUrl - e.g. https://admin.da.live/source/org/repo/path/doc.html
* @returns {{ origin: string, parent: string, name: string } | null}
* @param {string} sourceUrl a document url on either store
* @returns {{ parent: string, name: string } | null} the parent in the `/org/site/dir` form the
* source api takes, and the document name without its extension
*/
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 };
const segments = orgSiteAndRest(new URL(sourceUrl).pathname);
if (!segments || segments.length < 3) return null;
const name = segments[segments.length - 1].replace(/\.html?$/i, '');
return { parent: `/${segments.slice(0, -1).join('/')}`, name };
} catch {
return null;
}
Expand Down
5 changes: 3 additions & 2 deletions blocks/canvas/ew-editor-doc/prose.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { generateColor, getCollabIdentity } from './utils/collab.js';
import { checkBlockLibraryConfigured } from '../editor-utils/block-slash.js';
import { canvasBus } from '../utils/canvas-bus.js';

const { DA_ADMIN, DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`);
const { DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`);

function registerErrorHandler(ydoc) {
ydoc.on('update', () => {
Expand Down Expand Up @@ -94,7 +94,8 @@ export default async function initProse({
const ydoc = new Y.Doc();

const server = DA_COLLAB;
const roomName = `${DA_ADMIN}${new URL(path).pathname}`;
// da-collab reads the store off the room name, and `path` is already the store's source url.
const roomName = path;

const wsOpts = { protocols: ['yjs'] };
let lastSentToken = null;
Expand Down
9 changes: 3 additions & 6 deletions blocks/canvas/ew-editor-doc/utils/ctx.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { buildSourceUrl } from './source.js';

export function sourceUrlFromEditorCtx(ctx) {
return buildSourceUrl(ctx?.path);
}
import { normalizeSourcePath } from './source.js';

export function editorCtxHasOrgRepoPath(ctx) {
const { org, repo, path } = ctx ?? {};
return Boolean(org && repo && path);
}

// Stays synchronous: the render phase needs an answer without waiting on the store lookup.
export function editorDocCanLoad(ctx) {
return editorCtxHasOrgRepoPath(ctx) && Boolean(sourceUrlFromEditorCtx(ctx));
Comment thread
benpeter marked this conversation as resolved.
return editorCtxHasOrgRepoPath(ctx) && Boolean(normalizeSourcePath(ctx.path));
}

export function controllerPathnameFromEditorCtx(ctx) {
Expand Down
32 changes: 25 additions & 7 deletions blocks/canvas/ew-editor-doc/utils/load-editor-doc.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import { checkDoc } from './source.js';
import { buildSourceUrl, checkDoc } from './source.js';
import { initIms } from '../../../shared/utils.js';

export async function resolveEditorDocSession(sourceUrl) {
export function sessionErrorFromResponse(resp) {
const status = resp?.status;
if (typeof status !== 'number') return { ok: false, error: 'Could not reach the content store' };
if (resp.ok || status === 404) return null;
if (status === 401) return { ok: false, error: 'Sign in required' };
if (status === 403) return { ok: false, error: 'Not permitted' };
const detail = resp.headers?.get?.('x-error');
const reason = detail ? `: ${detail}` : '';
return { ok: false, error: `Could not load the document (${status})${reason}` };
}

// takes the ctx rather than a url, so the sign-in check runs before the store lookup needs a token
export async function resolveEditorDocSession(ctx) {
const ims = await initIms();
const token = ims?.accessToken?.token ?? null;
if (ims?.anonymous || !token) {
return { ok: false, error: 'Sign in required' };
}

const resp = await checkDoc(sourceUrl);
if (!resp.ok && resp.status !== 404) {
const error = resp.status === 401 ? 'Sign in required' : 'Not permitted';
return { ok: false, error };
let sourceUrl;
try {
sourceUrl = await buildSourceUrl(ctx?.path);
} catch {
return { ok: false, error: 'Could not reach the content store' };
}
if (!sourceUrl) return { ok: false, error: 'Could not reach the content store' };

const resp = await checkDoc(sourceUrl);
const failure = sessionErrorFromResponse(resp);
if (failure) return failure;

const permissions = resp.permissions || ['read'];
return { ok: true, token, permissions };
return { ok: true, token, permissions, sourceUrl };
}
Loading
Loading