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
26 changes: 20 additions & 6 deletions blocks/canvas/canvas.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getNx } from '../../scripts/utils.js';
import { getCommentsBridge } from './editor-utils/comments-bridge.js';
import {
normalizeCanvasEditorView,
readInitialCanvasEditorView,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand All @@ -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();

Expand All @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions blocks/canvas/comments/comment-highlight.css
Original file line number Diff line number Diff line change
@@ -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);
}
228 changes: 228 additions & 0 deletions blocks/canvas/comments/comment-plugin.js
Original file line number Diff line number Diff line change
@@ -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;
},
},
},
});
}
Loading