diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js
index d3f2a3b80..1cfd7d92d 100644
--- a/blocks/canvas/canvas.js
+++ b/blocks/canvas/canvas.js
@@ -1,4 +1,5 @@
import { getNx } from '../../scripts/utils.js';
+import { getCommentsBridge } from './editor-utils/comments-bridge.js';
import {
normalizeCanvasEditorView,
readInitialCanvasEditorView,
@@ -130,24 +131,26 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) {
syncEditorSplitLayout({ mountRoot, view: header.editorView });
}
-async function syncToolPanelViews(toolPanel, { org, site }) {
+async function syncToolPanelViews(toolPanel, { org, site }, panelName) {
const key = org && site ? `${org}/${site}` : null;
- if (key === toolPanel.dataset.extKey) return;
+ if (key === toolPanel.dataset.extKey) return false;
toolPanel.dataset.extKey = key ?? '';
if (!key) {
toolPanel.org = undefined;
toolPanel.site = undefined;
toolPanel.views = [];
- return;
+ return true;
}
const { getCanvasToolPanelViews } = await import('./ew-panel-extensions/helpers.js');
const views = await getCanvasToolPanelViews({ org, site });
- if (toolPanel.dataset.extKey !== key) return;
+ if (toolPanel.dataset.extKey !== key) return false;
toolPanel.org = org;
toolPanel.site = site;
+ if (panelName) toolPanel.pendingView = panelName;
toolPanel.views = views;
+ return true;
}
function hashState() {
@@ -191,6 +194,12 @@ const SHORTCUTS = [
altKey: true,
channel: canvasBus.newVersionRequest,
},
+ {
+ code: 'KeyM',
+ mod: true,
+ altKey: true,
+ channel: canvasBus.commentComposeRequest,
+ },
];
document.addEventListener('keydown', (e) => {
const modKey = isMac ? e.metaKey : e.ctrlKey;
@@ -208,6 +217,11 @@ document.addEventListener('keydown', (e) => {
canvasBus.newVersionRequest.subscribe(openNewVersionRow);
+canvasBus.commentComposeRequest.subscribe(() => {
+ getCommentsBridge().controller?.requestCompose();
+ openPanelSection('tools', 'comments');
+});
+
export default async function decorate(block) {
const { org, site } = hashState();
@@ -231,9 +245,9 @@ export default async function decorate(block) {
onShow: async (aside, id, options) => {
const toolPanel = aside?.querySelector('ew-tool-panel');
if (!toolPanel) return;
- await syncToolPanelViews(toolPanel, hashState());
+ const selected = await syncToolPanelViews(toolPanel, hashState(), id);
await toolPanel.updateComplete;
- if (id && toolPanel.views?.some((v) => v.id === id)) {
+ if (id && !selected && toolPanel.views?.some((v) => v.id === id)) {
await toolPanel.showPanel(id);
}
if (options?.newVersion) {
diff --git a/blocks/canvas/comments/comment-highlight.css b/blocks/canvas/comments/comment-highlight.css
new file mode 100644
index 000000000..2f065aef5
--- /dev/null
+++ b/blocks/canvas/comments/comment-highlight.css
@@ -0,0 +1,69 @@
+/* stylelint-disable selector-class-pattern */
+
+.da-prose-mirror {
+ --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));
+ --comment-pending-bg: light-dark(#ffe9fc, rgb(236 130 210 / 22%));
+ --comment-pending-border: light-dark(#ed74ed, rgb(244 170 225 / 95%));
+}
+
+@media (prefers-color-scheme: dark) {
+ .ew-comment-highlight:not(.ew-comment-highlight-pending, img, :has(> img)) {
+ background-color: color-mix(in srgb, var(--ew-comment-bg) 58%, transparent);
+ }
+
+ .ew-comment-highlight-active:not(.ew-comment-highlight-pending, img, :has(> img)) {
+ background-color: color-mix(in srgb, var(--ew-comment-bg) 72%, transparent);
+ }
+}
+
+.ew-comment-highlight {
+ --ew-comment-bg: var(--comment-highlight-bg);
+ --ew-comment-border-color: var(--comment-highlight-border);
+ --ew-comment-border-style: solid;
+
+ background-color: var(--ew-comment-bg);
+ border-bottom: 2px var(--ew-comment-border-style) var(--ew-comment-border-color);
+ cursor: pointer;
+ transition: background-color 0.2s, box-shadow 0.2s;
+
+ &:has(> img) {
+ padding: 6px;
+ }
+}
+
+.ew-comment-highlight-active {
+ --ew-comment-bg: var(--comment-highlight-bg-active);
+ --ew-comment-border-color: var(--comment-highlight-border-active);
+}
+
+.ew-comment-highlight-pending {
+ --ew-comment-bg: var(--comment-pending-bg);
+ --ew-comment-border-color: var(--comment-pending-border);
+ --ew-comment-border-style: dashed;
+}
+
+/* Per-author strong (weight 700) color at 20% opacity, per Spectrum spec. Pending
+ keeps its own theme. */
+.ew-comment-highlight.ew-comment-authored:not(.ew-comment-highlight-pending) {
+ --ew-comment-bg: color-mix(in srgb, var(--ew-comment-author-color) 20%, transparent);
+ --ew-comment-border-color: var(--ew-comment-author-color);
+}
+
+.ew-comment-highlight.ew-comment-authored.ew-comment-highlight-active {
+ --ew-comment-bg: color-mix(in srgb, var(--ew-comment-author-color) 35%, transparent);
+}
+
+.ProseMirror .ew-comment-highlight tr:first-child td,
+.ProseMirror .ew-comment-highlight tr:first-child th {
+ background-color: var(--ew-comment-bg);
+}
+
+img.ew-comment-highlight {
+ pointer-events: auto;
+ box-shadow:
+ 0 0 0 6px var(--ew-comment-bg),
+ 0 2px 0 6px var(--ew-comment-border-color);
+}
diff --git a/blocks/canvas/comments/comment-plugin.js b/blocks/canvas/comments/comment-plugin.js
new file mode 100644
index 000000000..00ba64071
--- /dev/null
+++ b/blocks/canvas/comments/comment-plugin.js
@@ -0,0 +1,228 @@
+import {
+ Plugin,
+ PluginKey,
+ Decoration,
+ DecorationSet,
+ ySyncPluginKey,
+} from 'da-y-wrapper';
+import { decodeAnchor } from './helpers/anchor.js';
+import { buildAuthorColorMap, authorColorSet } from './helpers/author-colors.js';
+
+export const commentPluginKey = new PluginKey('comments');
+
+export const SET_RANGES = 'setRanges';
+export const SET_SELECTED_THREAD = 'setSelectedThread';
+export const SET_PANEL_OPEN = 'setPanelOpen';
+export const SET_PENDING_ANCHOR = 'setPendingAnchor';
+
+const emptyState = () => ({
+ ranges: new Map(),
+ selectedThreadId: null,
+ panelOpen: false,
+ pendingAnchor: null,
+ needsResync: false,
+});
+
+const isVisible = (pluginState) => Boolean(pluginState?.panelOpen);
+
+function applyAction(prev, action) {
+ switch (action.type) {
+ case SET_RANGES:
+ if (prev.ranges === action.payload && !prev.needsResync) return prev;
+ return { ...prev, ranges: action.payload, needsResync: false };
+ case SET_SELECTED_THREAD: {
+ const next = action.payload ?? null;
+ if (prev.selectedThreadId === next) return prev;
+ return { ...prev, selectedThreadId: next };
+ }
+ case SET_PANEL_OPEN: {
+ const next = Boolean(action.payload);
+ if (prev.panelOpen === next) return prev;
+ return { ...prev, panelOpen: next };
+ }
+ case SET_PENDING_ANCHOR: {
+ const next = action.payload ?? null;
+ if (prev.pendingAnchor === next) return prev;
+ return { ...prev, pendingAnchor: next };
+ }
+ default:
+ return prev;
+ }
+}
+
+function applyPluginMeta(prev, meta) {
+ if (!meta) return prev;
+ if (meta.batch?.length) {
+ return meta.batch.reduce((p, step) => applyAction(p, step), prev);
+ }
+ return applyAction(prev, meta);
+}
+
+function mapRanges(prevRanges, tr) {
+ const next = new Map();
+ prevRanges.forEach((entry, id) => {
+ if (entry.anchorType === 'image' || entry.anchorType === 'table') {
+ const from = tr.mapping.map(entry.from, -1);
+ const typeName = entry.anchorType === 'image' ? 'image' : 'table';
+ const node = tr.doc.nodeAt(from);
+ if (node?.type.name === typeName) {
+ next.set(id, { ...entry, from, to: from + node.nodeSize });
+ return;
+ }
+ }
+ const from = tr.mapping.map(entry.from, 1);
+ const to = tr.mapping.map(entry.to, -1);
+ if (from < to) next.set(id, { ...entry, from, to });
+ });
+ return next;
+}
+
+function pushAnchorDecoration(decorations, { from, to, anchorType, spec }) {
+ if (anchorType === 'image' || anchorType === 'table') {
+ decorations.push(Decoration.node(from, to, spec));
+ return;
+ }
+ decorations.push(Decoration.inline(from, to, spec));
+}
+
+function computeRanges(store, state) {
+ const out = new Map();
+ if (!store) return out;
+ const colorMap = buildAuthorColorMap(store);
+ store.forEach((comment, id) => {
+ if (comment.threadId != null) return;
+ if (comment.resolved) return;
+ const range = decodeAnchor({ anchor: comment, state });
+ if (range) {
+ out.set(id, {
+ ...range,
+ anchorType: comment.anchorType,
+ color: authorColorSet(store, comment.author, colorMap).strong,
+ });
+ }
+ });
+ return out;
+}
+
+export default function commentPlugin({ controller, store }) {
+ return new Plugin({
+ key: commentPluginKey,
+
+ state: {
+ init() { return emptyState(); },
+ apply(tr, prev, _oldState, newState) {
+ const meta = tr.getMeta(commentPluginKey);
+ let next = meta ? applyPluginMeta(prev, meta) : prev;
+
+ if (!isVisible(prev) && isVisible(next)) {
+ next = { ...next, ranges: computeRanges(store, newState), needsResync: false };
+ } else if (tr.docChanged && isVisible(next)) {
+ const yMeta = ySyncPluginKey.getState(newState);
+ const mustRebuild = yMeta?.isUndoRedoOperation || yMeta?.isChangeOrigin;
+ if (mustRebuild) {
+ next = { ...next, ranges: computeRanges(store, newState), needsResync: false };
+ } else {
+ next = { ...next, ranges: mapRanges(next.ranges, tr), needsResync: true };
+ }
+ }
+ return next;
+ },
+ },
+
+ view(editorView) {
+ controller.bindView(editorView);
+
+ const onStoreChange = () => {
+ if (editorView.isDestroyed) return;
+ if (!isVisible(commentPluginKey.getState(editorView.state))) return;
+ const ranges = computeRanges(store, editorView.state);
+ editorView.dispatch(
+ editorView.state.tr.setMeta(commentPluginKey, { type: SET_RANGES, payload: ranges }),
+ );
+ };
+ store?.observe(onStoreChange);
+
+ return {
+ update(view, prevState) {
+ const prev = commentPluginKey.getState(prevState);
+ const next = commentPluginKey.getState(view.state);
+ controller.notifyPluginStateChange(prev, next);
+
+ if (view.state.doc !== prevState.doc) {
+ controller.notifyDocChange();
+ }
+
+ if (next.needsResync && isVisible(next)) {
+ queueMicrotask(() => {
+ if (view.isDestroyed) return;
+ const state = commentPluginKey.getState(view.state);
+ if (!state.needsResync || !isVisible(state)) return;
+ const ranges = computeRanges(store, view.state);
+ view.dispatch(
+ view.state.tr.setMeta(commentPluginKey, { type: SET_RANGES, payload: ranges }),
+ );
+ });
+ }
+ },
+ destroy() {
+ store?.unobserve(onStoreChange);
+ controller.bindView(null);
+ },
+ };
+ },
+
+ props: {
+ decorations(state) {
+ const pluginState = commentPluginKey.getState(state);
+ if (!isVisible(pluginState)) return DecorationSet.empty;
+
+ const decorations = [];
+ pluginState.ranges.forEach(({ from, to, anchorType, color }, threadId) => {
+ const isSelected = threadId === pluginState.selectedThreadId;
+ let cls = isSelected
+ ? 'ew-comment-highlight ew-comment-highlight-active'
+ : 'ew-comment-highlight';
+ const spec = { 'data-comment-thread': threadId };
+ if (color) {
+ cls += ' ew-comment-authored';
+ spec.style = `--ew-comment-author-color: ${color}`;
+ }
+ spec.class = cls;
+ pushAnchorDecoration(decorations, {
+ from,
+ to,
+ anchorType,
+ spec,
+ });
+ });
+
+ const { pendingAnchor } = pluginState;
+ const pendingRange = decodeAnchor({ anchor: pendingAnchor, state });
+ if (pendingRange) {
+ pushAnchorDecoration(decorations, {
+ from: pendingRange.from,
+ to: pendingRange.to,
+ anchorType: pendingAnchor?.anchorType,
+ spec: { class: 'ew-comment-highlight ew-comment-highlight-pending' },
+ });
+ }
+
+ return DecorationSet.create(state.doc, decorations);
+ },
+
+ handleDOMEvents: {
+ click(view, event) {
+ const pluginState = commentPluginKey.getState(view.state);
+ if (!isVisible(pluginState)) return false;
+ const target = event.target?.closest?.('[data-comment-thread]');
+ if (target) {
+ controller.setSelectedThread(target.getAttribute('data-comment-thread'));
+ return true;
+ }
+ if (pluginState.selectedThreadId) controller.setSelectedThread(null);
+ return false;
+ },
+ },
+ },
+ });
+}
diff --git a/blocks/canvas/comments/comments-panel.css b/blocks/canvas/comments/comments-panel.css
new file mode 100644
index 000000000..b794b1a6c
--- /dev/null
+++ b/blocks/canvas/comments/comments-panel.css
@@ -0,0 +1,455 @@
+:host {
+ display: block;
+ height: 100%;
+ min-height: 0;
+ font-family: var(--body-font-family);
+
+ --comments-panel-padding-left: 16px;
+ --comments-panel-top-offset: 16px;
+ --comments-card-gap: 16px;
+ --comment-surface: light-dark(#fff, var(--s2-gray-50));
+ --comment-border: var(--s2-gray-200);
+ --comment-border-hover: var(--s2-gray-400);
+ --comment-text-muted: var(--s2-gray-700);
+ --comment-text-placeholder: var(--s2-gray-600);
+}
+
+.ew-comments-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ margin: 0 auto;
+ padding-left: var(--comments-panel-padding-left);
+ border: none;
+}
+
+.ew-comments-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden auto;
+}
+
+.ew-comments-loading {
+ display: flex;
+ justify-content: center;
+ padding: 48px 0;
+}
+
+.ew-comments-spinner {
+ width: 28px;
+ height: 28px;
+ border: 3px solid var(--comment-border, #d5d5d5);
+ border-top-color: var(--comment-text-muted, #6e6e6e);
+ border-radius: 50%;
+ animation: ew-comments-spin 0.8s linear infinite;
+}
+
+@keyframes ew-comments-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.ew-comments-hint {
+ font-size: 12px;
+}
+
+.ew-comments-hint kbd {
+ display: inline-block;
+ padding: 2px 6px;
+ background: var(--s2-gray-75);
+ border: 1px solid var(--s2-gray-200);
+ border-radius: 4px;
+ font-family: var(--body-font-family);
+ font-size: 12px;
+ color: var(--s2-gray-800);
+ white-space: nowrap;
+}
+
+
+.ew-comments-list {
+ padding-right: 16px;
+}
+
+.ew-comments-threads-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.ew-comments-empty {
+ color: var(--s2-gray-700);
+ font-size: 14px;
+ text-align: center;
+ padding: 24px 0;
+}
+
+.ew-comments-detached-reference {
+ display: block;
+ font-size: 11px;
+ color: var(--comment-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ew-comment-tabs {
+ display: flex;
+ gap: 8px;
+ margin: 16px 0;
+ overflow-x: auto;
+}
+
+.ew-comment-tab {
+ display: inline-flex;
+ gap: 5px;
+ align-items: center;
+ padding: 6px 14px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--s2-gray-700);
+ font-family: var(--body-font-family);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background-color 0.15s ease, color 0.15s ease;
+}
+
+.ew-comment-tab:hover:not(.is-active) {
+ background: var(--s2-gray-100, #f0f0f0);
+}
+
+.ew-comment-tab.is-active {
+ background: var(--s2-gray-200, #e6e6e6);
+ color: var(--s2-gray-900, #000);
+}
+
+.ew-comment-tab-count {
+ color: inherit;
+}
+
+.ew-comments-thread-detail {
+ padding-right: 16px;
+ position: sticky;
+ top: var(--comments-panel-top-offset);
+ z-index: 10;
+}
+
+.ew-comments-back-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 0;
+ margin-bottom: 12px;
+ border: none;
+ background: transparent;
+ color: var(--s2-gray-700);
+ font-family: var(--body-font-family);
+ font-size: 14px;
+ cursor: pointer;
+}
+
+.ew-comments-back-btn:hover {
+ color: var(--s2-blue-800);
+}
+
+.ew-comment-card {
+ position: relative;
+ background: var(--comment-surface);
+ border: 1px solid var(--s2-gray-100);
+ border-radius: 8px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+}
+
+.ew-comments-inline-composer {
+ margin-bottom: 16px;
+ position: sticky;
+ top: var(--comments-panel-top-offset);
+ z-index: 10;
+}
+
+.ew-comment-card.resolved {
+ background: var(--s2-gray-50);
+}
+
+.ew-comments-thread-surface.is-preview {
+ margin-bottom: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+ animation: ew-comments-slide-in 0.2s ease-out;
+}
+
+.ew-comments-thread-surface.is-preview:hover {
+ background: var(--s2-gray-75);
+}
+
+.ew-comment-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.ew-comment {
+ margin-bottom: 12px;
+}
+
+.ew-comment:last-child {
+ margin-bottom: 0;
+ border-bottom: 0;
+}
+
+.ew-comment-header {
+ display: flex;
+ align-items: start;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.ew-comment-meta {
+ flex: 1;
+ min-width: 0;
+}
+
+.ew-comment-author {
+ display: block;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--s2-gray-800);
+}
+
+.ew-comment-time {
+ font-size: 12px;
+ color: var(--s2-gray-700);
+}
+
+.ew-comment-content {
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--s2-gray-800);
+ white-space: pre-wrap;
+ margin-left: 40px;
+ overflow-wrap: break-word;
+}
+
+.ew-comment-content.is-clamped {
+ line-clamp: 4;
+ display: -webkit-box;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.ew-comment-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.ew-comment-replies {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--s2-gray-200);
+}
+
+.ew-comments-thread-replies-summary {
+ display: inline-block;
+ margin-left: 40px;
+ font-size: 12px;
+ color: var(--s2-blue-800);
+ font-weight: 500;
+}
+
+.ew-comment-reply {
+ padding-left: 24px;
+ border-bottom: 1px solid var(--s2-gray-100);
+ margin-bottom: 24px;
+ padding-bottom: 12px;
+}
+
+.ew-comment-form {
+ margin-top: 12px;
+}
+
+.ew-comment-form .ew-comment-textarea {
+ width: 100%;
+ height: 100px;
+ padding: 8px var(--s2-spacing-200);
+ font-family: var(--body-font-family);
+ resize: none;
+}
+
+.ew-comments-reply-form:not(.ew-comments-reply-form-expanded) .ew-comment-form .ew-comment-textarea {
+ height: 36px;
+}
+
+.ew-comment-form-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.ew-comment-form-hint {
+ margin-top: var(--s2-spacing-200);
+ font-size: 11px;
+ color: var(--s2-gray-600);
+ text-align: right;
+}
+
+.ew-comment-form-hint kbd {
+ font-family: inherit;
+ font-size: 11px;
+ padding: 1px 5px;
+ background: var(--s2-gray-75);
+ border: 1px solid var(--s2-gray-200);
+ border-radius: 3px;
+}
+
+.ew-comment-thread-actions {
+ display: flex;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--s2-gray-200);
+}
+
+.ew-comments-resolved-info {
+ width: 100%;
+ font-size: 12px;
+ color: var(--s2-gray-600);
+ margin-left: 40px;
+}
+
+.ew-comment-form-actions button,
+.ew-comment-thread-actions button {
+ flex: 0 0 auto;
+}
+
+.ew-comments-reply-form {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--s2-gray-200);
+}
+
+@media (width <=600px) {
+ .ew-comments-panel {
+ width: 100%;
+ padding: 0 16px;
+ }
+}
+
+.ew-comment.is-loading {
+ position: relative;
+}
+
+.ew-comments-spinner-overlay {
+ position: absolute;
+ inset: 0;
+ background: color-mix(in srgb, var(--comment-surface) 70%, transparent);
+ border-radius: 8px;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ew-comments-spinner-overlay::after {
+ content: '';
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--s2-gray-300);
+ border-top-color: var(--s2-blue-800, #0265dc);
+ border-radius: 50%;
+ animation: ew-comments-spin 0.6s linear infinite;
+}
+
+.ew-comments-btn-spinner {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ margin-left: 4px;
+ margin-top: 2px;
+ border: 2px solid currentcolor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: ew-comments-spin 0.6s linear infinite;
+}
+
+@keyframes ew-comments-slide-in {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.ew-comments-icon {
+ display: inline-block;
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ fill: currentcolor;
+}
+
+.ew-comments-icon-checkmark,
+.ew-comments-icon-chevron-left {
+ width: 18px;
+ height: 18px;
+}
+
+.ew-comments-icon-detached {
+ color: var(--s2-orange-800);
+}
+
+.ew-comments-detached-badge {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+}
+
+.ew-comments-compose-anchor-preview {
+ display: flex;
+ gap: 6px;
+ align-items: baseline;
+ margin: 0 0 12px;
+ padding: 8px 12px;
+ background: var(--s2-gray-50);
+ border-left: 2px solid var(--s2-gray-400);
+ font-size: 11px;
+ color: var(--s2-gray-700);
+ min-width: 0;
+ border-radius: 4px;
+}
+
+.ew-comments-compose-anchor-label {
+ font-weight: 600;
+ color: var(--s2-gray-800);
+ flex-shrink: 0;
+}
+
+.ew-comments-compose-anchor-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ font-style: italic;
+}
\ No newline at end of file
diff --git a/blocks/canvas/comments/comments-panel.js b/blocks/canvas/comments/comments-panel.js
new file mode 100644
index 000000000..2d6b58ba4
--- /dev/null
+++ b/blocks/canvas/comments/comments-panel.js
@@ -0,0 +1,404 @@
+import { LitElement, html } from 'da-lit';
+import { getNx, getNx2 } from '../../../scripts/utils.js';
+import getSheet from '../../shared/sheet.js';
+import { openCommentsPanel, getCommentsBridge } from '../editor-utils/comments-bridge.js';
+import { canvasBus } from '../utils/canvas-bus.js';
+import { buildDeepLinkUrl, parseDeepLink } from './helpers/deep-link.js';
+import {
+ DRAFT_MODES,
+ makeNewDraft,
+ makeReplyDraft,
+ setDraftText,
+ shouldAdoptPendingAnchor,
+} from './helpers/draft-state.js';
+import {
+ renderListView,
+ renderThreadView,
+ renderConfirmDeleteDialog,
+} from './helpers/templates.js';
+
+await import(`${getNx()}/blocks/shared/menu/menu.js`);
+const sheet = await getSheet('/blocks/canvas/comments/comments-panel.css');
+const buttons = await getSheet(`${getNx2()}/styles/buttons.css`);
+const form = await getSheet(`${getNx2()}/styles/form.css`);
+
+let toastModulePromise;
+
+function loadToastModule() {
+ toastModulePromise ??= import(`${getNx2()}/blocks/shared/toast/toast.js`);
+ return toastModulePromise;
+}
+
+function formatToastMessage(text, description) {
+ const title = text?.trim();
+ if (!title) return '';
+ const body = description?.trim();
+ return body ? `${title}\n${body}` : title;
+}
+
+export class CommentsPanel extends LitElement {
+ static properties = {
+ controller: { attribute: false },
+ currentUser: { state: true },
+ _activeTab: { state: true },
+ _draft: { state: true },
+ _submitting: { state: true },
+ _submittingId: { state: true },
+ _pendingDelete: { state: true },
+ _threadGroups: { state: true },
+ };
+
+ constructor() {
+ super();
+ this._activeTab = 'active';
+ }
+
+ willUpdate(changedProps) {
+ if (changedProps.has('controller')) {
+ if (this.controller) this.recomputeThreadGroups();
+ else this._threadGroups = null;
+ }
+ this.syncDraftFromPendingAnchor();
+ }
+
+ recomputeThreadGroups() {
+ if (!this.controller || !this.controller.panelOpen) {
+ this._threadGroups = null;
+ return;
+ }
+ this._threadGroups = this.controller.getThreadGroups(
+ this.controller.getAttachedThreadIds() ?? null,
+ );
+ }
+
+ syncDraftFromPendingAnchor() {
+ if (!this.controller) return;
+ const pending = this.controller.pendingAnchor;
+ if (!shouldAdoptPendingAnchor(this._draft, pending)) return;
+ this._draft = makeNewDraft(pending);
+ this._activeTab = 'active';
+ }
+
+ setupObservers() {
+ this.teardownObservers();
+ if (!this.controller) return;
+
+ this._unsubController = this.controller.subscribe(({ reason }) => {
+ if (reason === 'counts' || reason === 'docChange' || reason === 'init'
+ || reason === 'panelOpen') {
+ this.recomputeThreadGroups();
+ }
+ if (reason === 'pendingAnchor' || reason === 'panelOpen') {
+ this.syncDraftFromPendingAnchor();
+ if (this._draft?.mode === DRAFT_MODES.NEW) this.focusDraftTextarea();
+ }
+ if (reason === 'panelOpen' && !this.controller.panelOpen) {
+ this._draft = null;
+ }
+ this.requestUpdate();
+ });
+
+ this.currentUser = this.controller.getCurrentUser();
+ this._unsubCurrentUser = this.controller.onCurrentUserChange(() => {
+ this.currentUser = this.controller.getCurrentUser();
+ });
+ }
+
+ teardownObservers() {
+ this._unsubController?.();
+ this._unsubController = null;
+ this._unsubCurrentUser?.();
+ this._unsubCurrentUser = null;
+ }
+
+ setupBusSubscriptions() {
+ this.teardownBusSubscriptions();
+ this._unsubControllerState = canvasBus.commentsControllerState
+ .subscribe((controller) => { this.controller = controller; });
+ this._unsubToolView = canvasBus.toolPanelViewState.subscribe((view) => {
+ this._activeToolView = view;
+ this.syncPanelOpen();
+ });
+ this.syncPanelOpen();
+ }
+
+ syncPanelOpen() {
+ this.controller?.setPanelOpen(this._activeToolView === 'comments');
+ }
+
+ teardownBusSubscriptions() {
+ this._unsubControllerState?.();
+ this._unsubControllerState = null;
+ this._unsubToolView?.();
+ this._unsubToolView = null;
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [
+ ...this.shadowRoot.adoptedStyleSheets, buttons, form, sheet,
+ ];
+ if (this.controller === undefined) this.controller = getCommentsBridge().controller;
+ this.setupObservers();
+ this.setupBusSubscriptions();
+ import('../../shared/da-dialog/da-dialog.js');
+ this.checkUrlForComment();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.teardownObservers();
+ this.teardownBusSubscriptions();
+ }
+
+ openCommentsHost() {
+ openCommentsPanel();
+ }
+
+ showToast({ text, description, variant } = {}) {
+ const message = formatToastMessage(text, description);
+ if (!message) return;
+ loadToastModule().then(({ showToast: nxShowToast, VARIANT_ERROR }) => {
+ nxShowToast({
+ text: message,
+ variant: variant === 'error' ? VARIANT_ERROR : undefined,
+ });
+ });
+ }
+
+ updated(changedProps) {
+ if (changedProps.has('controller')) {
+ this.setupObservers();
+ this.syncPanelOpen();
+ }
+ if (changedProps.has('_draft') && this._draft) this.focusDraftTextarea();
+ this.resolvePendingCommentLink();
+ }
+
+ async focusDraftTextarea() {
+ await this.updateComplete;
+ this.shadowRoot?.querySelector('.ew-comment-form .ew-comment-textarea')?.focus();
+ }
+
+ checkUrlForComment() {
+ const { commentId, cleaned } = parseDeepLink(new URL(window.location.href));
+ if (!commentId) return;
+ this.openCommentsHost();
+ this._pendingCommentLinkId = commentId;
+ this.resolvePendingCommentLink();
+ window.history.replaceState({}, '', cleaned.toString());
+ }
+
+ resolvePendingCommentLink() {
+ if (!this._pendingCommentLinkId || !this.controller) return;
+ const threadId = this.controller.findThreadForComment(this._pendingCommentLinkId);
+ if (!threadId) return;
+ this._pendingCommentLinkId = null;
+ this.controller.setSelectedThread(threadId);
+
+ requestAnimationFrame(() => {
+ this.controller?.scrollToThread(threadId, { behavior: 'smooth' });
+ });
+ }
+
+ getThreadById(threadId) {
+ if (!threadId || !this._threadGroups) return null;
+ const { active, detached, resolved } = this._threadGroups;
+ return [...active, ...detached, ...resolved].find((t) => t.id === threadId) ?? null;
+ }
+
+ selectThread(threadId) {
+ this.controller?.setSelectedThread(threadId);
+ this.controller?.scrollToThread(threadId);
+ this.cancelDraft();
+ }
+
+ backToList() {
+ this.controller?.setSelectedThread(null);
+ this.cancelDraft();
+ }
+
+ startReplyDraft(rootComment) {
+ this._draft = makeReplyDraft(rootComment.id);
+ }
+
+ cancelDraft() {
+ this._draft = null;
+ this.controller?.clearPendingAnchor();
+ }
+
+ updateDraftText(event) {
+ this._draft = setDraftText(this._draft, event.target.value);
+ }
+
+ handleDraftKeydown(event) {
+ if (event.key === 'Escape') this.cancelDraft();
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault();
+ this.submitDraft(event);
+ }
+ }
+
+ showErrorToast(description) {
+ this.showToast({ text: 'Error', description, variant: 'error' });
+ }
+
+ async submitDraft(event) {
+ event.preventDefault();
+ const body = this._draft?.text?.trim();
+ if (!body || !this.currentUser || this._submitting) return;
+
+ const user = this.currentUser;
+ const draft = this._draft;
+ this._submitting = true;
+ try {
+ if (draft.mode === DRAFT_MODES.NEW) {
+ const id = await this.controller.createRootComment(
+ { user, anchor: draft.anchorData, body },
+ );
+ this.controller.setSelectedThread(id);
+ } else if (draft.mode === DRAFT_MODES.REPLY) {
+ await this.controller.createReply({ user, threadId: draft.threadId, body });
+ }
+ this.controller.collapseSelection();
+ this.controller.clearPendingAnchor();
+ (this.shadowRoot?.activeElement ?? document.activeElement)?.blur();
+ this._draft = null;
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn('[comments] submit failed', err);
+ this.showErrorToast('Could not save comment. Please try again.');
+ } finally {
+ this._submitting = false;
+ }
+ }
+
+ async deleteComment(commentId) {
+ if (this._submittingId) return;
+ this._submittingId = commentId;
+ try {
+ await this.controller.deleteComment({ commentId });
+ if (this.controller.selectedThreadId === commentId) {
+ this.controller.setSelectedThread(null);
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn('[comments] delete failed', err);
+ this.showErrorToast('Could not delete comment. Please try again.');
+ } finally {
+ this._submittingId = null;
+ }
+ }
+
+ async handleResolveThread(threadId) {
+ if (this._submittingId) return;
+ this.cancelDraft();
+ this._submittingId = threadId;
+ try {
+ await this.controller.resolveThread({ threadId, user: this.currentUser });
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn('[comments] resolve failed', err);
+ this.showErrorToast('Could not resolve thread. Please try again.');
+ } finally {
+ this._submittingId = null;
+ }
+ }
+
+ async handleUnresolveThread(threadId) {
+ if (this._submittingId) return;
+ this._activeTab = 'active';
+ this._submittingId = threadId;
+ try {
+ await this.controller.unresolveThread({ threadId, user: this.currentUser });
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn('[comments] reopen failed', err);
+ this.showErrorToast('Could not reopen thread. Please try again.');
+ } finally {
+ this._submittingId = null;
+ }
+ }
+
+ handleDeleteComment(commentId, threadId = this.controller?.selectedThreadId) {
+ const thread = this.getThreadById(threadId);
+ if (!thread) return;
+
+ if (thread.id === commentId && thread.replies.length > 0) {
+ this._pendingDelete = { commentId };
+ return;
+ }
+ this.deleteComment(commentId);
+ }
+
+ handleConfirmDeleteComment() {
+ if (!this._pendingDelete) return;
+ this.deleteComment(this._pendingDelete.commentId);
+ this._pendingDelete = null;
+ }
+
+ handleDeleteThread(threadId) {
+ this.deleteComment(threadId);
+ this.cancelDraft();
+ this._activeTab = 'active';
+ }
+
+ handleMenuSelect(id, comment, threadId) {
+ if (id === 'delete') this.handleDeleteComment(comment.id, threadId);
+ else if (id === 'link') this.copyThreadLink(threadId);
+ }
+
+ canEditComment(comment) {
+ if (!comment || !this.currentUser) return false;
+ return this.currentUser.id === comment.author?.id;
+ }
+
+ copyThreadLink(threadId = this.controller?.selectedThreadId) {
+ if (!threadId) return;
+ const url = buildDeepLinkUrl(new URL(window.location.href), threadId);
+ navigator.clipboard.writeText(url.toString())
+ .then(() => {
+ this.showToast({ text: 'The link was copied to the clipboard.' });
+ })
+ .catch(() => {
+ this.showToast({
+ text: 'Error',
+ description: 'Could not copy link to clipboard.',
+ variant: 'error',
+ });
+ });
+ }
+
+ render() {
+ const { active, detached, resolved } = this._threadGroups
+ ?? { active: [], detached: [], resolved: [] };
+ const activeThreads = [...active, ...detached];
+ const visibleThreads = this._activeTab === 'resolved' ? resolved : activeThreads;
+ const tabCounts = { active: activeThreads.length, resolved: resolved.length };
+
+ const selectedThread = this.getThreadById(this.controller?.selectedThreadId);
+ const isComposing = this._draft?.mode === DRAFT_MODES.NEW && this.currentUser;
+ const isLoading = Boolean(this.controller) && !this.controller.loaded
+ && !selectedThread && !isComposing;
+
+ let content;
+ if (isLoading) {
+ content = html`
`;
+ } else if (selectedThread) {
+ content = renderThreadView(this, selectedThread);
+ } else {
+ content = renderListView(this, { visibleThreads, tabCounts });
+ }
+
+ return html`
+
+ ${renderConfirmDeleteDialog(this)}
+ `;
+ }
+}
+
+customElements.define('ew-comments', CommentsPanel);
diff --git a/blocks/canvas/comments/helpers/anchor.js b/blocks/canvas/comments/helpers/anchor.js
new file mode 100644
index 000000000..d23ee7825
--- /dev/null
+++ b/blocks/canvas/comments/helpers/anchor.js
@@ -0,0 +1,228 @@
+import {
+ Y,
+ ySyncPluginKey,
+ NodeSelection,
+ CellSelection,
+ absolutePositionToRelativePosition,
+ relativePositionToAbsolutePosition,
+} from 'da-y-wrapper';
+import { getTableInfo } from '../../../edit/prose/plugins/tableUtils.js';
+
+function encodeRelPos(relPos) {
+ return Array.from(Y.encodeRelativePosition(relPos));
+}
+
+function startRelPos({ from, anchorType }, binding) {
+ const atFrom = () => absolutePositionToRelativePosition(from, binding.type, binding.mapping);
+ if (anchorType !== 'text') return atFrom();
+ const inside = absolutePositionToRelativePosition(from + 1, binding.type, binding.mapping);
+ return inside?.item
+ ? new Y.RelativePosition(inside.type, inside.tname, inside.item, 0)
+ : atFrom();
+}
+
+function hashString(str) {
+ let h = 5381;
+ // eslint-disable-next-line no-bitwise
+ for (let i = 0; i < str.length; i += 1) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
+ return h;
+}
+
+function nodeAtPath(doc, path) {
+ let node = doc;
+ let start = 0;
+ for (const idx of path) {
+ if (!node || idx >= node.childCount) return null;
+ let before = 0;
+ for (let i = 0; i < idx; i += 1) before += node.child(i).nodeSize;
+ start += before + 1;
+ node = node.child(idx);
+ }
+ return { node, start };
+}
+
+function anchorHash(node, anchorType) {
+ if (anchorType === 'image') return hashString(node?.attrs?.src ?? '');
+ if (anchorType === 'text') return hashString(node?.textContent ?? '');
+ return 0;
+}
+
+function encodeStructural(state, { from, to, anchorType }) {
+ if (!state.doc) return null;
+ const $from = state.doc.resolve(from);
+ const path = [];
+ for (let d = 1; d <= $from.depth; d += 1) path.push($from.index(d - 1));
+
+ if (anchorType === 'image' || anchorType === 'table') {
+ const node = state.doc.nodeAt(from);
+ if (!node) return null;
+ path.push($from.index($from.depth));
+ return { path, offset: -1, length: node.nodeSize, hash: anchorHash(node, anchorType) };
+ }
+
+ const blockStart = $from.start($from.depth);
+ return {
+ path,
+ offset: state.doc.textBetween(blockStart, from).length,
+ length: state.doc.textBetween(from, to).length,
+ hash: anchorHash($from.parent, 'text'),
+ };
+}
+
+function resolveNodeAnchorRange(state, from, anchorType) {
+ if (!state.doc) return null;
+ const typeName = anchorType === 'image' ? 'image' : 'table';
+ const node = state.doc.nodeAt(from);
+ if (node?.type.name !== typeName) return null;
+ return { from, to: from + node.nodeSize };
+}
+
+function pmPosAtTextOffset(doc, rangeFrom, rangeTo, targetOffset) {
+ if (targetOffset <= 0) return rangeFrom;
+ let count = 0;
+ let matched = false;
+ let result = rangeTo;
+
+ doc.nodesBetween(rangeFrom, rangeTo, (node, pos) => {
+ if (matched || !node.isText) return;
+ const start = Math.max(pos, rangeFrom);
+ const end = Math.min(pos + node.nodeSize, rangeTo);
+ for (let p = start; p < end; p += 1) {
+ if (count === targetOffset) {
+ result = p;
+ matched = true;
+ return;
+ }
+ count += 1;
+ }
+ });
+
+ if (!matched && count === targetOffset) return rangeTo;
+ return matched ? result : null;
+}
+
+function decodeStructural(state, anchor) {
+ const structural = anchor?.structural;
+ if (!structural?.path || !state.doc) return null;
+ const located = nodeAtPath(state.doc, structural.path);
+ if (!located) return null;
+ if (anchor.structural.hash !== anchorHash(located.node, anchor.anchorType)) return null;
+ if (anchor.anchorType === 'image' || anchor.anchorType === 'table') {
+ const nodeFrom = located.start + structural.offset;
+ if (nodeFrom < 0 || nodeFrom + structural.length > state.doc.content.size) return null;
+ return resolveNodeAnchorRange(state, nodeFrom, anchor.anchorType);
+ }
+ const docEnd = state.doc.content.size;
+ const from = pmPosAtTextOffset(state.doc, located.start, docEnd, structural.offset);
+ const to = from == null
+ ? null
+ : pmPosAtTextOffset(state.doc, located.start, docEnd, structural.offset + structural.length);
+ if (from == null || to == null || from >= to) return null;
+ return { from, to };
+}
+
+function relPosMatchesHash(state, from, anchor) {
+ if (!anchor.structural || !state.doc) return true;
+ if (anchor.anchorType === 'image' || anchor.anchorType === 'table') {
+ return anchor.anchorType === 'table'
+ || anchor.structural.hash === anchorHash(state.doc.nodeAt(from), 'image');
+ }
+ return anchor.structural.hash === anchorHash(state.doc.resolve(from).parent, 'text');
+}
+
+function decodeRelPos(encoded) {
+ if (!Array.isArray(encoded) || encoded.length === 0) return null;
+ return Y.decodeRelativePosition(Uint8Array.from(encoded));
+}
+
+export function encodeAnchor({ selectionData, state }) {
+ if (!selectionData) return null;
+ const binding = ySyncPluginKey.getState(state)?.binding;
+ if (!binding) return null;
+ return {
+ anchorFrom: encodeRelPos(startRelPos(selectionData, binding)),
+ anchorTo: encodeRelPos(
+ absolutePositionToRelativePosition(selectionData.to, binding.type, binding.mapping),
+ ),
+ anchorType: selectionData.anchorType,
+ anchorText: selectionData.anchorText,
+ structural: encodeStructural(state, selectionData),
+ };
+}
+
+export function resolveAnchor({ anchor, state }) {
+ const none = { range: null, source: null };
+ if (!anchor?.anchorFrom || !anchor?.anchorTo) return none;
+ const binding = ySyncPluginKey.getState(state)?.binding;
+ if (!binding) return none;
+ const relFrom = decodeRelPos(anchor.anchorFrom);
+ const relTo = decodeRelPos(anchor.anchorTo);
+ if (!relFrom || !relTo) return none;
+ const { doc: yDoc, type, mapping } = binding;
+
+ try {
+ const from = relativePositionToAbsolutePosition(yDoc, type, relFrom, mapping);
+ const to = relativePositionToAbsolutePosition(yDoc, type, relTo, mapping);
+ if (from != null && to != null && from < to) {
+ const relResult = anchor.anchorType === 'image' || anchor.anchorType === 'table'
+ ? resolveNodeAnchorRange(state, from, anchor.anchorType)
+ : { from, to };
+ if (relResult) {
+ if (relPosMatchesHash(state, from, anchor)) return { range: relResult, source: 'relpos' };
+ const structural = decodeStructural(state, anchor);
+ return structural
+ ? { range: structural, source: 'structural' }
+ : { range: relResult, source: 'relpos' };
+ }
+ }
+ const structural = decodeStructural(state, anchor);
+ return structural ? { range: structural, source: 'structural' } : none;
+ } catch {
+ return none;
+ }
+}
+
+export function decodeAnchor({ anchor, state }) {
+ return resolveAnchor({ anchor, state }).range;
+}
+
+function nodeAnchorData(state, node, from, to) {
+ if (node.type.name === 'image') {
+ return { from, to, anchorType: 'image', anchorText: '' };
+ }
+ if (node.type.name === 'table') {
+ const tableInfo = getTableInfo(state, from + 3);
+ const name = tableInfo?.tableName?.trim();
+ return {
+ from,
+ to,
+ anchorType: 'table',
+ anchorText: name ? `block: ${name}` : '',
+ };
+ }
+ return null;
+}
+
+export function getSelectionData(state) {
+ const { selection, doc } = state;
+ if (!selection || selection.empty) return null;
+ if (selection instanceof CellSelection) return null;
+ const { from, to } = selection;
+
+ if (selection instanceof NodeSelection) {
+ return nodeAnchorData(state, selection.node, from, to);
+ }
+
+ const node = doc.nodeAt(from);
+ if (node && from + node.nodeSize === to) {
+ const nodeData = nodeAnchorData(state, node, from, to);
+ if (nodeData) return nodeData;
+ }
+
+ return {
+ from,
+ to,
+ anchorType: 'text',
+ anchorText: doc.textBetween(from, to, ' '),
+ };
+}
diff --git a/blocks/canvas/comments/helpers/author-colors.js b/blocks/canvas/comments/helpers/author-colors.js
new file mode 100644
index 000000000..40df02e9c
--- /dev/null
+++ b/blocks/canvas/comments/helpers/author-colors.js
@@ -0,0 +1,26 @@
+import { slotColorSet } from '../../editor-utils/author-color.js';
+
+export function authorKey(author) {
+ return author?.email || author?.id || '';
+}
+
+export function buildAuthorColorMap(store) {
+ const firstSeen = new Map();
+ store?.forEach((comment) => {
+ const key = authorKey(comment.author);
+ if (!key) return;
+ const at = comment.createdAt ?? 0;
+ if (!firstSeen.has(key) || at < firstSeen.get(key)) firstSeen.set(key, at);
+ });
+ const ordered = [...firstSeen.entries()]
+ .sort((a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : 1))
+ .map(([key]) => key);
+ const map = new Map();
+ ordered.forEach((key, i) => map.set(key, slotColorSet(i)));
+ return map;
+}
+
+export function authorColorSet(store, author, map) {
+ const colorMap = map ?? buildAuthorColorMap(store);
+ return colorMap.get(authorKey(author)) ?? slotColorSet(colorMap.size);
+}
diff --git a/blocks/canvas/comments/helpers/awareness-sync.js b/blocks/canvas/comments/helpers/awareness-sync.js
new file mode 100644
index 000000000..06c775edf
--- /dev/null
+++ b/blocks/canvas/comments/helpers/awareness-sync.js
@@ -0,0 +1,59 @@
+export function createAwarenessSync({ wsProvider, commentsStore: store }) {
+ const awareness = wsProvider?.awareness;
+
+ const broadcastChange = () => {
+ try {
+ awareness?.setLocalStateField('comments', { version: Date.now() });
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn('[comments] broadcast failed', err);
+ }
+ };
+
+ let onRemoteUpdate = null;
+ if (awareness) {
+ const lastSeenVersions = new Map();
+ onRemoteUpdate = ({ added, updated, removed }) => {
+ removed?.forEach((id) => lastSeenVersions.delete(id));
+ const myId = awareness.clientID;
+ const remoteChanged = [...added, ...updated]
+ .filter((id) => id !== myId)
+ .some((id) => {
+ const v = awareness.getStates().get(id)?.comments?.version;
+ if (!v || v === lastSeenVersions.get(id)) return false;
+ lastSeenVersions.set(id, v);
+ return true;
+ });
+ if (remoteChanged) store?.refresh();
+ };
+ awareness.on('update', onRemoteUpdate);
+ }
+
+ return {
+ broadcastChange,
+
+ getCurrentUser() {
+ return awareness?.getLocalState()?.user ?? null;
+ },
+
+ onCurrentUserChange(fn) {
+ if (!awareness) return () => {};
+ let prevUserId = awareness.getLocalState()?.user?.id;
+ const wrapped = () => {
+ const nextUserId = awareness.getLocalState()?.user?.id;
+ if (nextUserId === prevUserId) return;
+ prevUserId = nextUserId;
+ fn();
+ };
+ awareness.on('update', wrapped);
+ return () => awareness.off('update', wrapped);
+ },
+
+ destroy() {
+ if (onRemoteUpdate) {
+ awareness?.off('update', onRemoteUpdate);
+ onRemoteUpdate = null;
+ }
+ },
+ };
+}
diff --git a/blocks/canvas/comments/helpers/comments-store.js b/blocks/canvas/comments/helpers/comments-store.js
new file mode 100644
index 000000000..b52718187
--- /dev/null
+++ b/blocks/canvas/comments/helpers/comments-store.js
@@ -0,0 +1,111 @@
+import { DA_ORIGIN } from '../../../shared/constants.js';
+import { daFetch } from '../../../shared/utils.js';
+
+function commentFingerprint(c) {
+ return `${c.body}|${c.resolved}|${c.resolvedAt ?? ''}`;
+}
+
+export function createCommentsStore({ docId, owner, repo }) {
+ const map = new Map();
+ const observers = new Set();
+ let loaded = false;
+ const base = `${DA_ORIGIN}/source/${owner}/${repo}/.da/comments/${docId}`;
+ const listUrl = `${DA_ORIGIN}/list/${owner}/${repo}/.da/comments/${docId}/`;
+
+ const fire = () => observers.forEach((fn) => fn());
+
+ function settleLoaded(changed) {
+ const wasLoaded = loaded;
+ loaded = true;
+ if (changed || !wasLoaded) fire();
+ }
+
+ async function reload() {
+ const listResp = await daFetch(listUrl, { cache: 'no-store' });
+ if (!listResp.ok) {
+ settleLoaded(false);
+ return;
+ }
+ const list = await listResp.json();
+ const ids = list.map((entry) => entry.name.replace(/\.json$/, ''));
+
+ const fetched = await Promise.all(
+ ids.map((id) => daFetch(`${base}/${id}.json`, { cache: 'no-store' })
+ .then((r) => (r.ok ? r.json() : null))
+ .catch(() => null)),
+ );
+
+ const next = new Map();
+ fetched.forEach((value, i) => { if (value) next.set(ids[i], value); });
+
+ const changed = next.size !== map.size
+ || [...next.entries()].some(([k, v]) => {
+ const existing = map.get(k);
+ return !existing || commentFingerprint(existing) !== commentFingerprint(v);
+ });
+
+ map.clear();
+ next.forEach((v, k) => map.set(k, v));
+
+ settleLoaded(changed);
+ }
+
+ return {
+ get size() { return map.size; },
+
+ get loaded() { return loaded; },
+
+ forEach(fn) { map.forEach((value, id) => fn(value, id, this)); },
+
+ get(id) { return map.get(id); },
+
+ observe(fn) { observers.add(fn); },
+ unobserve(fn) { observers.delete(fn); },
+
+ async set(id, value) {
+ const body = new FormData();
+ body.append('data', new Blob([JSON.stringify(value)], { type: 'application/json' }));
+ const resp = await daFetch(`${base}/${id}.json`, { method: 'POST', body });
+ if (!resp.ok) {
+ throw new Error(`[comments] set ${id} failed: ${resp.status}`);
+ }
+ const changed = JSON.stringify(map.get(id)) !== JSON.stringify(value);
+ map.set(id, value);
+ if (changed) fire();
+ },
+
+ async delete(id) {
+ const resp = await daFetch(`${base}/${id}.json`, { method: 'DELETE' });
+ if (!resp.ok) {
+ throw new Error(`[comments] delete ${id} failed: ${resp.status}`);
+ }
+ if (map.has(id)) {
+ map.delete(id);
+ fire();
+ }
+ },
+
+ async deleteBatch(ids) {
+ const results = await Promise.all(
+ ids.map((id) => daFetch(`${base}/${id}.json`, { method: 'DELETE' })),
+ );
+ let changed = false;
+ const failed = [];
+ results.forEach((resp, i) => {
+ if (resp.ok) {
+ map.delete(ids[i]);
+ changed = true;
+ } else {
+ failed.push(`${ids[i]} (${resp.status})`);
+ }
+ });
+ if (changed) fire();
+ if (failed.length) {
+ throw new Error(`[comments] deleteBatch failed for: ${failed.join(', ')}`);
+ }
+ },
+
+ async load() { await reload(); },
+ async refresh() { await reload(); },
+ };
+}
diff --git a/blocks/canvas/comments/helpers/controller.js b/blocks/canvas/comments/helpers/controller.js
new file mode 100644
index 000000000..8487b0c35
--- /dev/null
+++ b/blocks/canvas/comments/helpers/controller.js
@@ -0,0 +1,311 @@
+import { TextSelection } from 'da-y-wrapper';
+import {
+ SET_SELECTED_THREAD,
+ SET_PANEL_OPEN,
+ SET_PENDING_ANCHOR,
+ commentPluginKey,
+} from '../comment-plugin.js';
+import { decodeAnchor, resolveAnchor, encodeAnchor, getSelectionData } from './anchor.js';
+import { createAwarenessSync } from './awareness-sync.js';
+import { computeCounts, buildThreadGroups } from './thread-grouping.js';
+import { buildAuthorColorMap, authorColorSet } from './author-colors.js';
+import { createChannel } from '../../utils/canvas-bus.js';
+
+export function createCommentsController({ commentsStore: store, wsProvider }) {
+ let authorColors = null;
+
+ const getAuthorColorMap = () => {
+ if (!authorColors) authorColors = buildAuthorColorMap(store);
+ return authorColors;
+ };
+
+ let counts = store ? computeCounts(store) : { active: 0, resolved: 0 };
+ let boundView = null;
+
+ const healing = new Set();
+
+ const reanchor = (id, comment, range) => {
+ if (!boundView || healing.has(id)) return;
+ const fresh = encodeAnchor({
+ selectionData: {
+ from: range.from,
+ to: range.to,
+ anchorType: comment.anchorType,
+ anchorText: comment.anchorText,
+ },
+ state: boundView.state,
+ });
+ if (!fresh?.anchorFrom) return;
+ healing.add(id);
+ Promise.resolve(store.set(id, { ...comment, ...fresh }))
+ .finally(() => healing.delete(id));
+ };
+
+ const changes = createChannel();
+ const emit = (reason) => changes.emit(reason);
+
+ const getPluginState = () => (
+ boundView ? commentPluginKey.getState(boundView.state) : null
+ );
+
+ const dispatchPluginMeta = (meta) => {
+ if (!boundView) return;
+ boundView.dispatch(boundView.state.tr.setMeta(commentPluginKey, meta));
+ };
+
+ const onStoreChange = () => {
+ counts = computeCounts(store);
+ authorColors = null;
+ emit('counts');
+ };
+
+ if (store) store.observe(onStoreChange);
+
+ const awareness = createAwarenessSync({ wsProvider, commentsStore: store });
+ const { broadcastChange } = awareness;
+
+ const controller = {
+ getCurrentUser: awareness.getCurrentUser,
+ onCurrentUserChange: awareness.onCurrentUserChange,
+
+ get panelOpen() {
+ return Boolean(getPluginState()?.panelOpen);
+ },
+
+ get selectedThreadId() {
+ return getPluginState()?.selectedThreadId ?? null;
+ },
+
+ get pendingAnchor() {
+ return getPluginState()?.pendingAnchor ?? null;
+ },
+
+ get loaded() { return store ? store.loaded : true; },
+
+ get counts() { return counts; },
+
+ getComment(id) { return store?.get(id); },
+ getCommentCount() { return store?.size ?? 0; },
+
+ authorColorSet(author) {
+ return authorColorSet(store, author, getAuthorColorMap());
+ },
+
+ getThreadGroups(attachedIds) {
+ return buildThreadGroups({ store, attachedIds });
+ },
+
+ findThreadForComment(commentId) {
+ const entry = store?.get(commentId);
+ if (!entry) return null;
+ return entry.threadId ?? commentId;
+ },
+
+ getAttachedThreadIds() {
+ if (!boundView || !store) return null;
+ const ids = new Set();
+ store.forEach((comment, id) => {
+ if (comment.threadId != null || comment.resolved) return;
+ const { range, source } = resolveAnchor({ anchor: comment, state: boundView.state });
+ if (!range) return;
+ ids.add(id);
+ if (source === 'structural') reanchor(id, comment, range);
+ });
+ return ids;
+ },
+
+ async createRootComment({ user, anchor, body, now = Date.now() }) {
+ const id = crypto.randomUUID();
+ await store.set(id, {
+ id,
+ threadId: null,
+ ...anchor,
+ author: user,
+ body,
+ createdAt: now,
+ resolved: false,
+ resolvedBy: null,
+ resolvedAt: null,
+ });
+ broadcastChange();
+ return id;
+ },
+
+ async createReply({ threadId, user, body, now = Date.now() }) {
+ const id = crypto.randomUUID();
+ await store.set(id, {
+ id,
+ threadId,
+ author: user,
+ body,
+ createdAt: now,
+ });
+ broadcastChange();
+ return id;
+ },
+
+ async resolveThread({ threadId, user, now = Date.now() }) {
+ const comment = store.get(threadId);
+ if (!comment) return;
+ await store.set(threadId, {
+ ...comment,
+ resolved: true,
+ resolvedBy: { id: user.id, name: user.name },
+ resolvedAt: now,
+ reopenedBy: null,
+ reopenedAt: null,
+ });
+ broadcastChange();
+ },
+
+ async unresolveThread({ threadId, user, now = Date.now() }) {
+ const comment = store.get(threadId);
+ if (!comment) return;
+ await store.set(threadId, {
+ ...comment,
+ resolved: false,
+ resolvedBy: null,
+ resolvedAt: null,
+ reopenedBy: { id: user.id, name: user.name },
+ reopenedAt: now,
+ });
+ broadcastChange();
+ },
+
+ async deleteComment({ commentId }) {
+ const comment = store.get(commentId);
+ if (!comment) return;
+ if (comment.threadId == null) {
+ const replyIds = [];
+ store.forEach((entry, id) => {
+ if (entry.threadId === commentId) replyIds.push(id);
+ });
+ await store.deleteBatch([...replyIds, commentId]);
+ } else {
+ await store.delete(commentId);
+ }
+ broadcastChange();
+ },
+
+ scrollToThread(threadId, { behavior = 'smooth' } = {}) {
+ if (!boundView || boundView.isDestroyed || !threadId || !store) return;
+ const comment = store.get(threadId);
+ if (!comment) return;
+ const range = decodeAnchor({ anchor: comment, state: boundView.state });
+ if (!range) return;
+ const { anchorType } = comment;
+ const isTableOrImage = anchorType === 'table' || anchorType === 'image';
+ let targetEl = null;
+ if (isTableOrImage) {
+ targetEl = boundView.nodeDOM(range.from);
+ } else {
+ const { node } = boundView.domAtPos(range.from);
+ targetEl = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
+ }
+
+ targetEl?.scrollIntoView({ behavior, block: 'start' });
+ },
+
+ collapseSelection() {
+ if (!boundView || boundView.isDestroyed) return;
+ const { state } = boundView;
+ if (state.selection.empty) return;
+ const { to } = state.selection;
+ boundView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, to)));
+ },
+
+ notifyDocChange() {
+ emit('docChange');
+ },
+
+ notifyPluginStateChange(prev, next) {
+ if (!next) return;
+ const prevPanel = prev?.panelOpen ?? false;
+ const prevThread = prev?.selectedThreadId ?? null;
+ const prevAnchor = prev?.pendingAnchor ?? null;
+
+ if (prevPanel !== next.panelOpen) emit('panelOpen');
+ if (prevThread !== next.selectedThreadId) emit('selectedThreadId');
+ if (prevAnchor !== next.pendingAnchor) emit('pendingAnchor');
+ },
+
+ bindView(view) {
+ boundView = view;
+ },
+
+ setPanelOpen(open) {
+ const next = Boolean(open);
+ const ps = getPluginState();
+ if (ps && ps.panelOpen === next) return;
+ dispatchPluginMeta({ type: SET_PANEL_OPEN, payload: next });
+ },
+
+ setSelectedThread(id) {
+ const next = id ?? null;
+ const ps = getPluginState();
+ if (ps && ps.selectedThreadId === next) return;
+ dispatchPluginMeta({ type: SET_SELECTED_THREAD, payload: next });
+ },
+
+ setPendingAnchor(anchor) {
+ const next = anchor ?? null;
+ const ps = getPluginState();
+ if (ps && ps.pendingAnchor === next) return;
+ dispatchPluginMeta({ type: SET_PENDING_ANCHOR, payload: next });
+ },
+
+ clearPendingAnchor() {
+ this.setPendingAnchor(null);
+ },
+
+ openPanel({ pendingAnchor: anchor = null } = {}) {
+ if (!boundView) return;
+ const batch = [];
+ if (anchor != null) {
+ batch.push({ type: SET_SELECTED_THREAD, payload: null });
+ }
+ batch.push(
+ { type: SET_PANEL_OPEN, payload: true },
+ { type: SET_PENDING_ANCHOR, payload: anchor },
+ );
+ dispatchPluginMeta({ batch });
+ },
+
+ closePanel() {
+ dispatchPluginMeta({
+ batch: [
+ { type: SET_PANEL_OPEN, payload: false },
+ { type: SET_SELECTED_THREAD, payload: null },
+ { type: SET_PENDING_ANCHOR, payload: null },
+ ],
+ });
+ },
+
+ requestCompose() {
+ if (!boundView) return;
+ const selectionData = getSelectionData(boundView.state);
+ const anchor = selectionData
+ ? encodeAnchor({ selectionData, state: boundView.state })
+ : null;
+ this.openPanel({ pendingAnchor: anchor });
+ },
+
+ on(reason, fn) {
+ return changes.subscribe((r) => { if (r === reason) fn(); });
+ },
+
+ subscribe(fn) {
+ const off = changes.subscribe((reason) => fn({ reason }));
+ fn({ reason: 'init' });
+ return off;
+ },
+
+ destroy() {
+ if (store) store.unobserve(onStoreChange);
+ awareness.destroy();
+ boundView = null;
+ },
+ };
+
+ return controller;
+}
diff --git a/blocks/canvas/comments/helpers/deep-link.js b/blocks/canvas/comments/helpers/deep-link.js
new file mode 100644
index 000000000..0f868e9b5
--- /dev/null
+++ b/blocks/canvas/comments/helpers/deep-link.js
@@ -0,0 +1,13 @@
+export function buildDeepLinkUrl(url, commentId) {
+ const out = new URL(url.toString());
+ out.searchParams.set('comment', commentId);
+ return out;
+}
+
+export function parseDeepLink(url) {
+ const commentId = url.searchParams.get('comment');
+ if (!commentId) return { commentId: null, cleaned: url };
+ const cleaned = new URL(url.toString());
+ cleaned.searchParams.delete('comment');
+ return { commentId, cleaned };
+}
diff --git a/blocks/canvas/comments/helpers/draft-state.js b/blocks/canvas/comments/helpers/draft-state.js
new file mode 100644
index 000000000..30937621f
--- /dev/null
+++ b/blocks/canvas/comments/helpers/draft-state.js
@@ -0,0 +1,27 @@
+export const DRAFT_MODES = Object.freeze({ NEW: 'new', REPLY: 'reply' });
+
+export function makeNewDraft(anchorData) {
+ return { mode: DRAFT_MODES.NEW, anchorData, text: '' };
+}
+
+export function makeReplyDraft(threadId) {
+ return { mode: DRAFT_MODES.REPLY, threadId, text: '' };
+}
+
+export function setDraftText(draft, text) {
+ if (!draft) return null;
+ return { ...draft, text };
+}
+
+export function hasUnsavedText(draft) {
+ return Boolean(draft?.text?.trim());
+}
+
+export function shouldAdoptPendingAnchor(currentDraft, pendingAnchor) {
+ if (!pendingAnchor) return false;
+ if (!currentDraft) return true;
+ if (currentDraft.mode === DRAFT_MODES.NEW) {
+ return currentDraft.anchorData !== pendingAnchor;
+ }
+ return !hasUnsavedText(currentDraft);
+}
diff --git a/blocks/canvas/comments/helpers/format-utils.js b/blocks/canvas/comments/helpers/format-utils.js
new file mode 100644
index 000000000..57dfa1017
--- /dev/null
+++ b/blocks/canvas/comments/helpers/format-utils.js
@@ -0,0 +1,71 @@
+export function formatTimestamp(timestamp) {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return 'Just now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+
+ return date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
+ });
+}
+
+export function formatFullTimestamp(timestamp) {
+ const date = new Date(timestamp);
+ return date.toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZoneName: 'short',
+ });
+}
+
+export function formatAnchorPreview(anchor) {
+ if (!anchor) return '';
+ if (anchor.anchorType === 'image') return 'an image';
+ if (anchor.anchorType === 'table') {
+ const text = (anchor.anchorText || '').trim();
+ return text || 'a table';
+ }
+ const text = (anchor.anchorText || '').trim();
+ if (!text) return '';
+ const truncated = text.length > 80 ? `${text.slice(0, 80).trim()}…` : text;
+ return `"${truncated}"`;
+}
+
+export function getInitials(name) {
+ if (!name) return '?';
+ const parts = name.split(' ').filter(Boolean);
+ if (parts.length === 0) return '?';
+ if (parts.length === 1) return parts[0].substring(0, 2).toUpperCase();
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+}
+
+export function getReplySummary({ rootComment, replies }) {
+ const uniqueAuthors = [];
+ const seen = new Set();
+
+ replies.forEach((reply) => {
+ const authorId = reply.author?.id;
+ if (!authorId || authorId === rootComment.author?.id || seen.has(authorId)) return;
+ seen.add(authorId);
+ uniqueAuthors.push(reply.author.name);
+ });
+
+ if (uniqueAuthors.length === 0) return '';
+ if (uniqueAuthors.length === 1) return ` from ${uniqueAuthors[0]}`;
+ if (uniqueAuthors.length === 2) return ` from ${uniqueAuthors[0]} and ${uniqueAuthors[1]}`;
+
+ const remainingCount = uniqueAuthors.length - 2;
+ return ` from ${uniqueAuthors[0]}, ${uniqueAuthors[1]} and ${remainingCount} ${remainingCount === 1 ? 'other' : 'others'}`;
+}
diff --git a/blocks/canvas/comments/helpers/templates.js b/blocks/canvas/comments/helpers/templates.js
new file mode 100644
index 000000000..c3612f147
--- /dev/null
+++ b/blocks/canvas/comments/helpers/templates.js
@@ -0,0 +1,292 @@
+/* eslint-disable no-underscore-dangle */
+
+import { html, nothing } from 'da-lit';
+import * as formatUtils from './format-utils.js';
+import { DRAFT_MODES } from './draft-state.js';
+import { generateColorSet } from '../../editor-utils/author-color.js';
+
+const IS_MAC = /Mac|iPhone|iPad/.test(navigator.userAgent);
+export const COMMENT_SHORTCUT = IS_MAC ? '⌘ + Option + M' : 'Ctrl + Alt + M';
+export const SUBMIT_SHORTCUT = IS_MAC ? '⌘ + Enter' : 'Ctrl + Enter';
+
+const ICONS = {
+ checkmark: '/img/icons/s2-icon-checkmark-20-n.svg',
+ 'chevron-left': '/img/icons/s2-icon-chevronleft-20-n.svg',
+ more: '/img/icons/s2-icon-more-20-n.svg',
+ detached: '/img/icons/s2-icon-alerttriangle-20-n.svg',
+};
+
+function renderIcon(name) {
+ return html``;
+}
+
+export function renderAvatar(panel, author) {
+ const set = panel.controller?.authorColorSet
+ ? panel.controller.authorColorSet(author)
+ : generateColorSet(author.email || author.id || '');
+ return html`
+
+ `;
+}
+
+export function renderForm(panel, {
+ placeholder, submitLabel, value, formClass = '', showActions = true, onFocus,
+}) {
+ return html`
+
+ `;
+}
+
+export function renderCommentMenu(panel, comment, threadId, isRoot, canEdit) {
+ if (!canEdit && !isRoot) return nothing;
+ const items = [
+ ...(canEdit ? [{ id: 'delete', label: 'Delete' }] : []),
+ ...(isRoot ? [{ id: 'link', label: 'Get link to this comment' }] : []),
+ ];
+
+ return html`
+ panel.handleMenuSelect(e.detail.id, comment, threadId)}>
+
+
+ `;
+}
+
+export function renderDetachedReference(comment) {
+ if (comment.anchorType === 'image') {
+ return html``;
+ }
+ if (comment.anchorType === 'table') {
+ const preview = formatUtils.formatAnchorPreview(comment);
+ if (preview === 'a table') {
+ return html``;
+ }
+ return html``;
+ }
+ if (!comment.anchorText) return nothing;
+ return html``;
+}
+
+export function renderComment(panel, {
+ comment, threadId, isRoot = false, isResolved = false,
+ isDetached = false, isPreview = false,
+}) {
+ const canEdit = panel.canEditComment(comment);
+ const showMenu = !isPreview && !isResolved && (isRoot || canEdit);
+ const showResolve = !isPreview && isRoot && !isResolved && !!panel.currentUser;
+
+ const isSpinning = !isRoot && panel._submittingId === comment.id;
+ return html`
+
+ `;
+}
+
+export function renderStatusLine(label, user, at) {
+ if (!user) return nothing;
+ return html`
+
+ `;
+}
+
+export function renderThreadPreview(panel, thread) {
+ const { id: threadId, replies, isDetached, isResolved } = thread;
+ return html`
+
+
+
+ `;
+}
+
+export function renderListView(panel, viewModel) {
+ if (panel._draft?.mode === DRAFT_MODES.NEW && panel.currentUser) {
+ const preview = formatUtils.formatAnchorPreview(panel._draft.anchorData);
+ return html`
+
+ `;
+ }
+
+ if (!panel.controller) {
+ return html``;
+ }
+
+ const { tabCounts, visibleThreads } = viewModel;
+ const tabs = [
+ { id: 'active', label: 'Active', count: tabCounts.active },
+ { id: 'resolved', label: 'Resolved', count: tabCounts.resolved },
+ ].filter((t) => t.count > 0 || t.id === 'active');
+
+ return html`
+
+ `;
+}
+
+export function renderThreadView(panel, thread) {
+ const { id: threadId, replies, isDetached, isResolved } = thread;
+ const isReplying = panel._draft?.mode === DRAFT_MODES.REPLY
+ && panel._draft?.threadId === threadId;
+
+ return html`
+
+ `;
+}
+
+export function renderConfirmDeleteDialog(panel) {
+ if (!panel._pendingDelete) return nothing;
+ return html`
+ panel.handleConfirmDeleteComment() }}
+ @close=${() => { panel._pendingDelete = null; }}>
+ Deleting the comment will remove the entire thread.
+
+ `;
+}
diff --git a/blocks/canvas/comments/helpers/thread-grouping.js b/blocks/canvas/comments/helpers/thread-grouping.js
new file mode 100644
index 000000000..78162d880
--- /dev/null
+++ b/blocks/canvas/comments/helpers/thread-grouping.js
@@ -0,0 +1,50 @@
+export function computeCounts(store) {
+ let active = 0;
+ let resolved = 0;
+ store.forEach((c) => {
+ if (c.threadId != null) return;
+ if (c.resolved) resolved += 1;
+ else active += 1;
+ });
+ return { active, resolved };
+}
+
+export function buildThreadGroups({ store, attachedIds }) {
+ if (!store) return { active: [], detached: [], resolved: [] };
+
+ const roots = new Map();
+ const replyGroups = new Map();
+
+ store.forEach((comment) => {
+ if (comment.threadId == null) {
+ roots.set(comment.id, comment);
+ } else {
+ const group = replyGroups.get(comment.threadId) ?? [];
+ group.push(comment);
+ replyGroups.set(comment.threadId, group);
+ }
+ });
+
+ const active = [];
+ const detached = [];
+ const resolved = [];
+
+ roots.forEach((root) => {
+ const replies = (replyGroups.get(root.id) ?? [])
+ .sort((a, b) => a.createdAt - b.createdAt);
+
+ if (root.resolved) {
+ resolved.push({ ...root, replies, isDetached: false, isResolved: true });
+ } else if (attachedIds == null || attachedIds.has(root.id)) {
+ active.push({ ...root, replies, isDetached: false, isResolved: false });
+ } else {
+ detached.push({ ...root, replies, isDetached: true, isResolved: false });
+ }
+ });
+
+ active.sort((a, b) => b.createdAt - a.createdAt);
+ detached.sort((a, b) => b.createdAt - a.createdAt);
+ resolved.sort((a, b) => b.resolvedAt - a.resolvedAt);
+
+ return { active, detached, resolved };
+}
diff --git a/blocks/canvas/editor-utils/author-color.js b/blocks/canvas/editor-utils/author-color.js
new file mode 100644
index 000000000..468eae2a6
--- /dev/null
+++ b/blocks/canvas/editor-utils/author-color.js
@@ -0,0 +1,57 @@
+const AUTHOR_PALETTE = [
+ { bg: '#ffbcb4', text: '#68150a', strong: '#ff513d' }, // red
+ { bg: '#ffc15e', text: '#5f2000', strong: '#e86a00' }, // orange
+ { bg: '#f5c700', text: '#4b2f00', strong: '#c18300' }, // yellow
+ { bg: '#b6db00', text: '#2f3900', strong: '#809900' }, // chartreuse
+ { bg: '#81e43a', text: '#1b3c03', strong: '#52a119' }, // celery
+ { bg: '#6be3a2', text: '#003d2c', strong: '#0ba45d' }, // green
+ { bg: '#5ce1c2', text: '#003c36', strong: '#0ba286' }, // seafoam
+ { bg: '#8ad5ff', text: '#00394e', strong: '#1d95e7' }, // cyan
+ { bg: '#accffd', text: '#10288c', strong: '#5d89ff' }, // blue
+ { bg: '#c0c9ff', text: '#3706a0', strong: '#8480fe' }, // indigo
+ { bg: '#ddc1f6', text: '#4b0090', strong: '#b272eb' }, // purple
+ { bg: '#f7b5ff', text: '#5c046d', strong: '#df4df5' }, // fuchsia
+ { bg: '#ffb9d0', text: '#6f0028', strong: '#ff4885' }, // magenta
+ { bg: '#ffb5e6', text: '#690344', strong: '#f24cb8' }, // pink
+];
+
+function hashString(name) {
+ let hash = 0;
+ const str = name ?? '';
+ for (let i = 0; i < str.length; i += 1) {
+ // eslint-disable-next-line no-bitwise
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return Math.abs(hash);
+}
+
+export function generateColorSet(name) {
+ return AUTHOR_PALETTE[hashString(name) % AUTHOR_PALETTE.length];
+}
+
+export function slotColorSet(index) {
+ const n = AUTHOR_PALETTE.length;
+ return AUTHOR_PALETTE[((index % n) + n) % n];
+}
+
+export function colorSetForColor(color) {
+ return AUTHOR_PALETTE.find((set) => set.bg === color) ?? null;
+}
+
+export function generateColor(name) {
+ return generateColorSet(name).bg;
+}
+
+export function collabCursorBuilder(user) {
+ const cursor = document.createElement('span');
+ cursor.classList.add('ProseMirror-yjs-cursor');
+ cursor.setAttribute('style', `border-color: ${user.color}`);
+ const label = document.createElement('div');
+ label.style.backgroundColor = user.color;
+ label.style.color = colorSetForColor(user.color)?.text ?? '#1e1e1e';
+ label.insertBefore(document.createTextNode(user.name), null);
+ cursor.insertBefore(document.createTextNode('\u2060'), null);
+ cursor.insertBefore(label, null);
+ cursor.insertBefore(document.createTextNode('\u2060'), null);
+ return cursor;
+}
diff --git a/blocks/canvas/editor-utils/command-defs.js b/blocks/canvas/editor-utils/command-defs.js
index 6944d8be1..b05398d5b 100644
--- a/blocks/canvas/editor-utils/command-defs.js
+++ b/blocks/canvas/editor-utils/command-defs.js
@@ -28,6 +28,8 @@ import {
isImageNodeSelected,
selectionHasLink,
removeLink,
+ requestComment,
+ canComment,
} from './command-helpers.js';
import { openLinkDialog, openAltDialog, triggerAddImage } from './selection-toolbar.js';
import { blockItemsForQuery, hasLibrary, insertBlockItem, getState } from './block-slash.js';
@@ -340,6 +342,16 @@ export const COMMANDS = [
apply: triggerAddImage,
},
+ // Toolbar: comment (end of the toolbar, after image)
+ {
+ id: 'add-comment',
+ label: 'Comment',
+ icon: iconName('comment'),
+ showIn: ['toolbar-comment'],
+ visible: canComment,
+ apply: requestComment,
+ },
+
// Slash menu: text section only
{
id: 'section-break',
diff --git a/blocks/canvas/editor-utils/command-helpers.js b/blocks/canvas/editor-utils/command-helpers.js
index 0be9ef963..20efabd1a 100644
--- a/blocks/canvas/editor-utils/command-helpers.js
+++ b/blocks/canvas/editor-utils/command-helpers.js
@@ -18,6 +18,8 @@ import {
splitCell,
isInTable,
} from 'da-y-wrapper';
+import { getCommentsBridge, openCommentsPanel } from './comments-bridge.js';
+import { getSelectionData } from '../comments/helpers/anchor.js';
/* ---- Apply factories ---- */
@@ -274,6 +276,17 @@ export function removeLink(view) {
view.dispatch(tr);
}
+/* ---- Comments ---- */
+
+export function requestComment(_) {
+ openCommentsPanel();
+ getCommentsBridge().controller?.requestCompose();
+}
+
+export function canComment(state) {
+ return getSelectionData(state) != null;
+}
+
/* ---- Block-type picker value ---- */
const SCHEMA_NODE_TO_ID = new Map([
diff --git a/blocks/canvas/editor-utils/comments-bridge.js b/blocks/canvas/editor-utils/comments-bridge.js
new file mode 100644
index 000000000..e39ba9e3e
--- /dev/null
+++ b/blocks/canvas/editor-utils/comments-bridge.js
@@ -0,0 +1,51 @@
+import { getNx } from '../../../scripts/utils.js';
+import { canvasBus } from '../utils/canvas-bus.js';
+
+let panelEventsPromise;
+const panelEvents = () => {
+ panelEventsPromise ??= import(`${getNx()}/utils/panel.js`);
+ return panelEventsPromise;
+};
+
+const bridge = { controller: null };
+
+export function getCommentsBridge() {
+ return bridge;
+}
+
+export function setCommentsController(controller) {
+ bridge.controller = controller ?? null;
+ canvasBus.commentsControllerState.emit(bridge.controller);
+}
+
+export function formatCommentsViewLabel(activeCount) {
+ const count = Number(activeCount) || 0;
+ return count > 0 ? `Comments (${count})` : 'Comments';
+}
+
+export async function openCommentsPanel() {
+ const { PANEL_EVENT } = await panelEvents();
+ document.dispatchEvent(new CustomEvent(PANEL_EVENT.OPEN, { detail: { section: 'tools', id: 'comments' } }));
+}
+
+export async function closeCommentsPanel() {
+ const { PANEL_EVENT } = await panelEvents();
+ const aside = document.querySelector('aside.panel[data-position="after"]');
+ aside?.dispatchEvent(new CustomEvent(PANEL_EVENT.CLOSE, { bubbles: true, composed: true }));
+}
+
+export function getCommentsVisible() {
+ const { controller } = bridge;
+ return Boolean(controller?.panelOpen);
+}
+
+export function toggleComments() {
+ const { controller } = bridge;
+ if (!controller) return;
+ if (controller.panelOpen) {
+ controller.closePanel();
+ closeCommentsPanel();
+ } else {
+ openCommentsPanel();
+ }
+}
diff --git a/blocks/canvas/ew-block-toolbar/ew-block-toolbar.js b/blocks/canvas/ew-block-toolbar/ew-block-toolbar.js
index 512488f76..9f7d67167 100644
--- a/blocks/canvas/ew-block-toolbar/ew-block-toolbar.js
+++ b/blocks/canvas/ew-block-toolbar/ew-block-toolbar.js
@@ -3,6 +3,7 @@ import { getNx } from '../../../scripts/utils.js';
import { getBlocksExtension, loadBlockLibrary } from '../ew-panel-extensions/helpers.js';
import { replaceBlockRange, setTableBlockVariant, appendBlockRow } from '../editor-utils/blocks.js';
import { isMultiBlock, getMultiBlockTemplateRow } from '../editor-utils/multi-block.js';
+import { requestComment } from '../editor-utils/command-helpers.js';
import { canvasBus } from '../utils/canvas-bus.js';
const nx = getNx();
@@ -170,6 +171,12 @@ class EwBlockToolbar extends LitElement {
canvasBus.blockEditRequest.emit({ pos });
}
+ _onComment() {
+ if (!this.view) return;
+ requestComment(this.view);
+ this.hide();
+ }
+
_onDeleteBlock() {
const { view } = this;
if (!view) return;
@@ -248,6 +255,14 @@ class EwBlockToolbar extends LitElement {
title="Delete block"
@click=${() => this._onDeleteBlock()}
>${this._icon('delete')}
+
+
`;
}
diff --git a/blocks/canvas/ew-canvas-header/ew-canvas-header.css b/blocks/canvas/ew-canvas-header/ew-canvas-header.css
index fd3e6b5c7..1d316b68e 100644
--- a/blocks/canvas/ew-canvas-header/ew-canvas-header.css
+++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.css
@@ -12,50 +12,6 @@
color: var(--s2-gray-800);
}
-.icon-btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- min-width: 24px;
- min-height: 24px;
- padding: 0 4px;
- margin: 0;
- border: none;
- border-radius: 8px;
- font: inherit;
- font-size: var(--s2-body-size-xs, 0.75rem);
- color: var(--s2-gray-800);
- background: transparent;
- cursor: pointer;
-}
-
-.icon-btn svg.icon {
- display: block;
- flex-shrink: 0;
- width: 16px;
- height: 16px;
- overflow: hidden;
-}
-
-.icon-btn:focus-visible {
- outline: 2px solid var(--s2-blue-800);
- outline-offset: 2px;
-}
-
-.icon-btn:disabled {
- color: var(--s2-gray-400);
- cursor: not-allowed;
-}
-
-.icon-btn:hover:not(:disabled) {
- background-color: var(--s2-gray-75);
-}
-
-.icon-btn:disabled svg.icon {
- opacity: 0.45;
-}
-
.segmented {
display: inline-flex;
align-items: center;
@@ -132,6 +88,30 @@
border-bottom: 1px solid var(--s2-gray-200);
}
+.comments-toggle {
+ position: relative;
+ overflow: visible;
+}
+
+.comment-count-chip {
+ position: absolute;
+ top: -2px;
+ right: -2px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 999px;
+ background-color: var(--s2-blue-800, #0265dc);
+ color: #fff;
+ font-size: 10px;
+ font-weight: var(--s2-component-s-medium-font-weight, 700);
+ line-height: 1;
+}
+
.group {
display: flex;
align-items: center;
@@ -164,3 +144,11 @@
order: 1;
}
}
+
+.read-only-icon {
+ box-sizing: border-box;
+
+ &:hover {
+ cursor: auto;
+ }
+}
diff --git a/blocks/canvas/ew-canvas-header/ew-canvas-header.js b/blocks/canvas/ew-canvas-header/ew-canvas-header.js
index 810e88406..146849061 100644
--- a/blocks/canvas/ew-canvas-header/ew-canvas-header.js
+++ b/blocks/canvas/ew-canvas-header/ew-canvas-header.js
@@ -3,6 +3,7 @@ import { LitElement, html, nothing } from 'da-lit';
import { getNx, getNx2, getNxEWFlags } from '../../../scripts/utils.js';
import getSheet from '../../shared/sheet.js';
import { canvasBus } from '../utils/canvas-bus.js';
+import { getCommentsBridge, toggleComments, getCommentsVisible } from '../editor-utils/comments-bridge.js';
const { loadStyle, hashChange } = await import(`${getNx()}/utils/utils.js`);
const { PANEL_EVENT, getSectionAtPosition } = await import(`${getNx()}/utils/panel.js`);
@@ -17,6 +18,7 @@ const ICONS = {
splitRight: '/img/icons/s2-icon-splitright-20-n.svg',
gridCompare: '/img/icons/s2-icon-gridcompare-20-n.svg',
lock: '/img/icons/s2-icon-lock-20-n.svg',
+ comment: '/img/icons/s2-icon-chat-20-n.svg',
};
const EDITOR_VIEWS = /** @type {const} */ (['layout', 'content', 'split']);
@@ -30,6 +32,8 @@ class EWCanvasHeader extends LitElement {
authorized: { type: Boolean },
canWrite: { type: Boolean },
_chatDisabled: { state: true },
+ _commentsVisible: { state: true },
+ _commentCount: { state: true },
};
constructor() {
@@ -39,6 +43,8 @@ class EWCanvasHeader extends LitElement {
this.redoAvailable = false;
this.authorized = true;
this.canWrite = true;
+ this._commentsVisible = false;
+ this._commentCount = 0;
}
connectedCallback() {
@@ -47,11 +53,37 @@ class EWCanvasHeader extends LitElement {
this._unsubHash = hashChange.subscribe((state) => {
this._syncChatDisabled(state?.org, state?.site);
});
+ this._unsubControllerChange = canvasBus.commentsControllerState
+ .subscribe(() => this._bindComments());
+ this._bindComments();
}
disconnectedCallback() {
super.disconnectedCallback();
this._unsubHash?.();
+ this._unsubControllerChange?.();
+ this._unbindComments?.();
+ }
+
+ _bindComments() {
+ this._unbindComments?.();
+ const syncVisible = () => { this._commentsVisible = getCommentsVisible(); };
+ const syncCount = () => {
+ this._commentCount = getCommentsBridge().controller?.counts?.active ?? 0;
+ };
+ syncVisible();
+ syncCount();
+ const { controller } = getCommentsBridge();
+ if (!controller?.on) {
+ this._unbindComments = null;
+ return;
+ }
+ const offs = [controller.on('panelOpen', syncVisible), controller.on('counts', syncCount)];
+ this._unbindComments = () => offs.forEach((off) => off?.());
+ }
+
+ _toggleComments() {
+ toggleComments();
}
async _syncChatDisabled(org, site) {
@@ -94,7 +126,7 @@ class EWCanvasHeader extends LitElement {
_renderLock() {
const label = "Read-only — you don't have write access";
return html`
-
+
${this._renderIcon('lock')}
`;
}
@@ -104,17 +136,17 @@ class EWCanvasHeader extends LitElement {