Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions docs/quick-edit-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ parallel one. If you do add a new key:
| `STORED_MARKS` | iframe → host | da-live only |
| `PREVIEW` | iframe ↔ host (request/reply) | standalone (quick-edit-portal) only |
| `IMAGE_REPLACE` | iframe ↔ host (request/reply) | both hosts |
| `SET_COMMENT_MARKERS` | Host → iframe | da-live only |
| `SCROLL_TO_POS` | Host → iframe | da-live only |
| `COMMENT_MARKER_CLICK` | iframe → host | da-live only |
| `COMMENT_MARKER_CLEAR` | iframe → host | da-live only |
| `COMMENT_SHORTCUT` | iframe → host | da-live only |

---

Expand Down Expand Up @@ -118,6 +123,28 @@ upload request, the host replies with the same `IMAGE_REPLACE` type, distinguish
`payload.error` (failure) vs `payload.newSrc` (success). Both hosts implement the full
round-trip.

### Comments (`SET_COMMENT_MARKERS` / `SCROLL_TO_POS` / `COMMENT_MARKER_CLICK` / `COMMENT_MARKER_CLEAR` / `COMMENT_SHORTCUT`)

Drive the comments feature's overlay in layout/WYSIWYG mode. The comments UI (the panel,
threads, and anchoring) lives host-side in da-live; the iframe only renders a DOM overlay
of marker bubbles and highlights on top of the previewed page (`nx/public/plugins/quick-edit/src/comments.js`).

- `SET_COMMENT_MARKERS` — host pushes the current marker set (and which thread is selected)
whenever comments change or panel visibility toggles; the iframe redraws the overlay from
it. An empty set clears the overlay.
- `COMMENT_MARKER_CLICK` — the user clicked a marker/highlight in the overlay; the host
selects that thread, scrolls to it, and opens the comments panel.
- `COMMENT_MARKER_CLEAR` — the user clicked empty space; the host clears the selected thread.
- `COMMENT_SHORTCUT` — the user pressed the comment shortcut (Cmd/Ctrl+Alt+M) while focused
inside the iframe; the host opens the comments panel and starts a new comment on the
current selection. (The host page has its own keydown handler for the same shortcut when
focus is outside the iframe — see da-live's `canvas.js`.)
- `SCROLL_TO_POS` — host asks the iframe to scroll a comment's anchor position into view in
layout mode (e.g. after selecting a thread in the panel).

All five are da-live-embedded only; the standalone `quick-edit-portal.js` host has no
comments UI, so none are wired up there.

## Known gaps

- **Several payload fields are sent but not read by any current receiver:**
Expand Down
106 changes: 105 additions & 1 deletion nx/public/plugins/quick-edit/quick-edit.css
Original file line number Diff line number Diff line change
Expand Up @@ -357,4 +357,108 @@ da-palette .da-palette-field {
background-color: currentcolor;
mask: url("/nx/img/icons/S2_Icon_Select_20_N.svg") no-repeat center / contain;
box-sizing: border-box;
}
}

.qe-comment-overlay {
--comment-highlight-bg: light-dark(#fbf198, rgb(255 200 50 / 24%));
--comment-highlight-bg-active: light-dark(#f8d904, rgb(255 220 90 / 38%));
--comment-highlight-border: light-dark(#f8d904, rgb(255 210 70 / 90%));
--comment-highlight-border-active: light-dark(#e8c600, rgb(255 238 130));

position: absolute;
top: 0;
left: 0;
pointer-events: none;
z-index: 9998;
}

.qe-comment-box {
position: absolute;
margin: 0;
padding: 0;
border: none;
pointer-events: auto;
cursor: pointer;
box-sizing: border-box;
}

.qe-comment-box-text {
--da-comment-bg: var(--comment-highlight-bg);
--da-comment-border-color: var(--comment-highlight-border);

pointer-events: none;
background: light-dark(
color-mix(in srgb, var(--da-comment-bg) 42%, transparent),
color-mix(in srgb, var(--da-comment-bg) 55%, transparent)
);
border-bottom: 2px solid var(--da-comment-border-color);
z-index: 2;
}

.qe-comment-box.is-active {
--da-comment-bg: var(--comment-highlight-bg-active);
--da-comment-border-color: var(--comment-highlight-border-active);
}

.qe-comment-box-text.is-active {
background: light-dark(
color-mix(in srgb, var(--da-comment-bg) 55%, transparent),
color-mix(in srgb, var(--da-comment-bg) 65%, transparent)
);
border-bottom-color: var(--da-comment-border-color);
}

.qe-comment-box-text.is-authored {
background: color-mix(in srgb, var(--da-comment-color) 20%, transparent);
border-bottom-color: var(--da-comment-color);
}

.qe-comment-box-text.is-authored.is-active {
background: color-mix(in srgb, var(--da-comment-color) 34%, transparent);
}

.qe-comment-box-anchor-block {
pointer-events: none;
z-index: 1;
background: transparent !important;
outline: 2px solid var(--comment-highlight-border-active);
outline-offset: 3px;
}

.qe-comment-box-anchor-block.is-authored {
outline-color: var(--da-comment-color);
}

.qe-comment-bubble {
position: absolute;
box-sizing: content-box;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
margin: 0;
border: 2px solid #fff;
border-radius: 50% 50% 50% 2px;
background: var(--da-comment-color, var(--comment-highlight-border));
color: #fff;
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1;
filter:
drop-shadow(0 2px 8px rgb(0 0 0 / 8%))
drop-shadow(0 1px 4px rgb(0 0 0 / 4%))
drop-shadow(0 0 1px rgb(0 0 0 / 8%));
pointer-events: auto;
cursor: pointer;
z-index: 9999;
}

.qe-comment-bubble:hover {
filter:
drop-shadow(0 4px 12px rgb(0 0 0 / 8%))
drop-shadow(0 2px 6px rgb(0 0 0 / 4%))
drop-shadow(0 0 2px rgb(0 0 0 / 12%));
}
12 changes: 12 additions & 0 deletions nx/public/plugins/quick-edit/quick-edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
isStandaloneShell,
relayControllerMessage,
} from './src/standalone.js';
import {
setCommentMarkers,
applyCommentMarkers,
setupCommentShortcut,
scrollToProseIndex,
} from './src/comments/index.js';
import {
setupNodeSelection,
setSelectedNode,
Expand All @@ -38,6 +44,7 @@ async function setBody(body, ctx) {
document.body.innerHTML = doc.body.innerHTML;
await ctx.loadPage(document);
restoreBlockIndices(doc, document);
applyCommentMarkers(ctx);
setupNodeSelection(ctx);
setSelectedNode(getSelectedNode());
setupContentEditableListeners(ctx);
Expand Down Expand Up @@ -73,6 +80,10 @@ function onMessage(e, ctx) {
const { newSrc, originalSrc } = payload;
updateImageSrc(originalSrc, newSrc);
}
} else if (type === MESSAGE_TYPES.SET_COMMENT_MARKERS) {
setCommentMarkers(payload, ctx);
} else if (type === MESSAGE_TYPES.SCROLL_TO_POS) {
scrollToProseIndex(payload.proseIndex);
} else if (type === MESSAGE_TYPES.SET_SELECTED_NODE) {
setSelectedNode(payload.node, document, { scrollIntoView: payload.scrollIntoView });
}
Expand Down Expand Up @@ -104,6 +115,7 @@ function setupParentController(loadPage) {
};
port.onmessage = (ev) => onMessage(ev, ctx);
port.postMessage({ type: MESSAGE_TYPES.READY });
setupCommentShortcut(ctx);

window.removeEventListener('message', listener);
};
Expand Down
162 changes: 162 additions & 0 deletions nx/public/plugins/quick-edit/src/comments/geometry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { parseIndex } from '../utils.js';
import {
findBlock,
findImageAtProseIndex,
pictureSrc,
srcPathsMatch,
safeQuerySelectorAll,
walkProsePositions,
} from '../dom-index.js';

export const MARKER_SIZE = 14;

function blockClassFromAnchor(anchorText) {
return String(anchorText || '').replace(/^block:\s*/, '').trim().split(/\s+/)[0] || '';
}

function findBlockByRange(from, to, root) {
const rangeEnd = typeof to === 'number' && to > from ? to : from + 1;
let best = null;
let bestIndex = Infinity;
root.querySelectorAll('[data-block-index]').forEach((el) => {
const idx = parseIndex(el.getAttribute('data-block-index'));
if (idx == null || idx < from || idx >= rangeEnd) return;
if (idx < bestIndex) {
bestIndex = idx;
best = el;
}
});
return best;
}

export function findBlockForMarker(marker, root = document) {
const byRange = findBlockByRange(marker.from, marker.to, root);
if (byRange) return byRange;

const name = blockClassFromAnchor(marker.anchorText);
if (!name) return null;
const candidates = safeQuerySelectorAll(root, `div.${CSS.escape(name)}`);
return candidates.length === 1 ? candidates[0] : null;
}

function buildRangeAtContentStart(block, contentStart, from, to) {
let startNode = null;
let startOffset = 0;
let endNode = null;
let endOffset = 0;
let foundStart = false;
let foundEnd = false;

walkProsePositions(block, contentStart, {
onText(node, pos, len) {
if (!foundStart && from >= pos && from <= pos + len) {
startNode = node;
startOffset = from - pos;
foundStart = true;
}
if (!foundEnd && to > pos && to <= pos + len) {
endNode = node;
endOffset = to - pos;
foundEnd = true;
}
},
onAtomic(el, pos) {
const endPos = pos + 1;
if (!foundStart && from >= pos && from <= endPos) {
startNode = el.parentNode;
startOffset = Array.from(startNode.childNodes).indexOf(el);
foundStart = true;
}
if (!foundEnd && to > pos && to <= endPos) {
endNode = el.parentNode;
endOffset = Array.from(endNode.childNodes).indexOf(el) + 1;
foundEnd = true;
}
},
});

if (!startNode || !endNode) return null;
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
return range;
}

export function mapProseRangeToDomRange(block, blockProseIndex, from, to) {
if (to - from <= 0) return null;
return buildRangeAtContentStart(block, blockProseIndex, from, to);
}

export function findPictureForImageMarker(marker, root = document) {
const byIndex = findImageAtProseIndex(marker.from, root);
if (byIndex) return byIndex;

const src = marker.imageSrc;
if (!src) return null;

const block = findBlock(marker.from, root) || findBlockForMarker(marker, root);
const pool = block
? [...block.querySelectorAll('picture')]
: [...root.querySelectorAll('picture')];
const matches = pool.filter((pic) => srcPathsMatch(src, pictureSrc(pic)));
if (matches.length === 1) return matches[0];
if (matches.length > 1) {
const indexed = matches.filter((pic) => pic.hasAttribute('data-prose-index'));
if (indexed.length === 1) return indexed[0];
const { from } = marker;
const near = matches
.map((pic) => ({ pic, idx: parseIndex(pic.getAttribute('data-prose-index')) }))
.filter(({ idx }) => idx != null && Math.abs(idx - from) <= 2)
.sort((a, b) => Math.abs(a.idx - from) - Math.abs(b.idx - from));
if (near.length) return near[0].pic;
}
return matches[0] ?? null;
}

export function adjustTextHighlightRect(rect) {
const insetTop = Math.min(6, Math.max(0, (rect.height - 16) / 2));
return {
left: rect.left,
top: rect.top + insetTop,
width: rect.width,
height: Math.max(4, rect.height - insetTop),
};
}

export function markerDotPagePosition(rect, placement = 'top-left', size = MARKER_SIZE) {
const scrollLeft = window.scrollX;
const scrollTop = window.scrollY;
if (placement === 'center') {
return {
left: rect.left + (rect.width / 2) - (size / 2) + scrollLeft,
top: rect.top + (rect.height / 2) - (size / 2) + scrollTop,
};
}
if (placement === 'top-right') {
return {
left: rect.right - size + scrollLeft,
top: rect.top + scrollTop,
};
}
if (placement === 'text-start') {
return {
left: rect.left + scrollLeft,
top: rect.top - size + 2 + scrollTop,
};
}
return {
left: rect.left + scrollLeft,
top: rect.top + scrollTop,
};
}

export function imageHighlightElement(image) {
if (image.tagName !== 'PICTURE') return image;
const img = image.querySelector('img');
if (!img) return image;
const imgRect = img.getBoundingClientRect();
if (imgRect.width || imgRect.height) return img;
const pictureRect = image.getBoundingClientRect();
if (pictureRect.width || pictureRect.height) return image;
return img;
}
Loading
Loading