@mce/comments 0.24.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CommentLayer.vue.d.ts +3 -0
- package/dist/index.css +185 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +485 -0
- package/dist/plugin.d.ts +18 -0
- package/dist/useComments.d.ts +70 -0
- package/package.json +63 -0
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
package/dist/index.css
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
.m-comments {
|
|
2
|
+
position: absolute;
|
|
3
|
+
inset: 0;
|
|
4
|
+
pointer-events: none;
|
|
5
|
+
}
|
|
6
|
+
.m-comments__catcher {
|
|
7
|
+
position: absolute;
|
|
8
|
+
inset: 0;
|
|
9
|
+
pointer-events: auto;
|
|
10
|
+
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 24 24'%3E%3Cpath d='M9,22A1,1 0 0,1 8,21V18H4A2,2 0 0,1 2,16V4C2,2.89 2.9,2 4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H13.9L10.2,21.71C10,21.9 9.75,22 9.5,22V22H9M10,16V19.08L13,16H20V4H4V16H10Z' fill='black' stroke='white' stroke-width='1'/%3E%3C/svg%3E") 10 26, copy;
|
|
11
|
+
}
|
|
12
|
+
.m-comments__pin {
|
|
13
|
+
position: absolute;
|
|
14
|
+
z-index: 10;
|
|
15
|
+
transform: translate(0, -100%);
|
|
16
|
+
pointer-events: auto;
|
|
17
|
+
width: 28px;
|
|
18
|
+
height: 28px;
|
|
19
|
+
border: 2px solid #fff;
|
|
20
|
+
border-radius: 50% 50% 50% 2px;
|
|
21
|
+
color: #fff;
|
|
22
|
+
font-size: 0.75rem;
|
|
23
|
+
font-weight: 700;
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
justify-content: center;
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
29
|
+
}
|
|
30
|
+
.m-comments__pin--active {
|
|
31
|
+
outline: 2px solid rgba(var(--m-theme-primary), 0.6);
|
|
32
|
+
outline-offset: 1px;
|
|
33
|
+
}
|
|
34
|
+
.m-comments__pin-count {
|
|
35
|
+
position: absolute;
|
|
36
|
+
top: -6px;
|
|
37
|
+
right: -6px;
|
|
38
|
+
min-width: 15px;
|
|
39
|
+
height: 15px;
|
|
40
|
+
padding: 0 3px;
|
|
41
|
+
border-radius: 8px;
|
|
42
|
+
background: rgb(var(--m-theme-primary));
|
|
43
|
+
color: rgb(var(--m-theme-on-primary));
|
|
44
|
+
font-size: 0.5625rem;
|
|
45
|
+
line-height: 15px;
|
|
46
|
+
text-align: center;
|
|
47
|
+
}
|
|
48
|
+
.m-comments__panel {
|
|
49
|
+
position: absolute;
|
|
50
|
+
z-index: 11;
|
|
51
|
+
pointer-events: auto;
|
|
52
|
+
width: 280px;
|
|
53
|
+
background: rgb(var(--m-theme-surface));
|
|
54
|
+
color: rgb(var(--m-theme-on-surface));
|
|
55
|
+
border-radius: 12px;
|
|
56
|
+
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(var(--m-theme-on-surface), 0.06);
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
}
|
|
59
|
+
.m-comments__close {
|
|
60
|
+
position: absolute;
|
|
61
|
+
top: 8px;
|
|
62
|
+
right: 8px;
|
|
63
|
+
z-index: 1;
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
justify-content: center;
|
|
67
|
+
width: 24px;
|
|
68
|
+
height: 24px;
|
|
69
|
+
padding: 0;
|
|
70
|
+
border: none;
|
|
71
|
+
border-radius: 6px;
|
|
72
|
+
background: none;
|
|
73
|
+
color: rgba(var(--m-theme-on-surface), 0.45);
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
}
|
|
76
|
+
.m-comments__close:hover {
|
|
77
|
+
background: rgba(var(--m-theme-on-surface), 0.08);
|
|
78
|
+
color: rgba(var(--m-theme-on-surface), 0.8);
|
|
79
|
+
}
|
|
80
|
+
.m-comments__list {
|
|
81
|
+
max-height: 280px;
|
|
82
|
+
overflow-y: auto;
|
|
83
|
+
padding: 14px 12px 4px;
|
|
84
|
+
}
|
|
85
|
+
.m-comments__item {
|
|
86
|
+
display: flex;
|
|
87
|
+
gap: 8px;
|
|
88
|
+
margin-bottom: 12px;
|
|
89
|
+
}
|
|
90
|
+
.m-comments__avatar {
|
|
91
|
+
flex-shrink: 0;
|
|
92
|
+
width: 24px;
|
|
93
|
+
height: 24px;
|
|
94
|
+
border-radius: 50%;
|
|
95
|
+
color: #fff;
|
|
96
|
+
font-size: 0.6875rem;
|
|
97
|
+
font-weight: 700;
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
}
|
|
102
|
+
.m-comments__bubble {
|
|
103
|
+
flex: 1;
|
|
104
|
+
min-width: 0;
|
|
105
|
+
}
|
|
106
|
+
.m-comments__meta {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: baseline;
|
|
109
|
+
gap: 6px;
|
|
110
|
+
}
|
|
111
|
+
.m-comments__name {
|
|
112
|
+
font-size: 0.8125rem;
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
}
|
|
115
|
+
.m-comments__time {
|
|
116
|
+
font-size: 0.6875rem;
|
|
117
|
+
color: rgba(var(--m-theme-on-surface), 0.45);
|
|
118
|
+
}
|
|
119
|
+
.m-comments__text {
|
|
120
|
+
font-size: 0.8125rem;
|
|
121
|
+
line-height: 1.5;
|
|
122
|
+
word-break: break-word;
|
|
123
|
+
white-space: pre-wrap;
|
|
124
|
+
}
|
|
125
|
+
.m-comments__reply {
|
|
126
|
+
padding: 4px 12px 8px;
|
|
127
|
+
}
|
|
128
|
+
.m-comments__input, .m-comments__textarea {
|
|
129
|
+
width: 100%;
|
|
130
|
+
border: 1px solid rgba(var(--m-theme-on-surface), 0.14);
|
|
131
|
+
border-radius: 8px;
|
|
132
|
+
background: none;
|
|
133
|
+
color: inherit;
|
|
134
|
+
font: inherit;
|
|
135
|
+
font-size: 0.8125rem;
|
|
136
|
+
padding: 8px 10px;
|
|
137
|
+
outline: none;
|
|
138
|
+
box-sizing: border-box;
|
|
139
|
+
}
|
|
140
|
+
.m-comments__input:focus, .m-comments__textarea:focus {
|
|
141
|
+
border-color: rgb(var(--m-theme-primary));
|
|
142
|
+
}
|
|
143
|
+
.m-comments__textarea {
|
|
144
|
+
resize: none;
|
|
145
|
+
height: 64px;
|
|
146
|
+
}
|
|
147
|
+
.m-comments__panel--draft {
|
|
148
|
+
padding: 30px 10px 10px;
|
|
149
|
+
}
|
|
150
|
+
.m-comments__actions {
|
|
151
|
+
display: flex;
|
|
152
|
+
justify-content: flex-end;
|
|
153
|
+
gap: 8px;
|
|
154
|
+
padding: 0 12px 12px;
|
|
155
|
+
}
|
|
156
|
+
.m-comments__panel--draft .m-comments__actions {
|
|
157
|
+
padding: 8px 0 0;
|
|
158
|
+
}
|
|
159
|
+
.m-comments__btn {
|
|
160
|
+
border: 1px solid rgba(var(--m-theme-on-surface), 0.14);
|
|
161
|
+
background: none;
|
|
162
|
+
color: inherit;
|
|
163
|
+
border-radius: 7px;
|
|
164
|
+
padding: 0 12px;
|
|
165
|
+
height: 30px;
|
|
166
|
+
font-size: 0.8125rem;
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
}
|
|
169
|
+
.m-comments__btn:not(.m-comments__btn--primary):hover {
|
|
170
|
+
background: rgba(var(--m-theme-on-surface), 0.05);
|
|
171
|
+
border-color: rgba(var(--m-theme-on-surface), 0.24);
|
|
172
|
+
}
|
|
173
|
+
.m-comments__btn--primary {
|
|
174
|
+
border: none;
|
|
175
|
+
background: rgb(var(--m-theme-primary));
|
|
176
|
+
color: rgb(var(--m-theme-on-primary));
|
|
177
|
+
font-weight: 600;
|
|
178
|
+
}
|
|
179
|
+
.m-comments__btn--primary:hover {
|
|
180
|
+
filter: brightness(0.92);
|
|
181
|
+
}
|
|
182
|
+
.m-comments__btn:disabled {
|
|
183
|
+
opacity: 0.5;
|
|
184
|
+
cursor: not-allowed;
|
|
185
|
+
}/*$vite$:1*/
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { definePlugin, useEditor } from "mce";
|
|
2
|
+
import { Fragment, computed, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, defineComponent, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, ref, renderList, toDisplayString, unref, vModelText, watch, withDirectives, withKeys, withModifiers } from "vue";
|
|
3
|
+
//#region src/useComments.ts
|
|
4
|
+
function uid(prefix) {
|
|
5
|
+
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 评论数据层:评论作为元素能力存于 `element.comments`(idoc CommentThread[]),
|
|
9
|
+
* 经 modern-canvas 的 Element2DComments + mce CRDT 逐线程同步。
|
|
10
|
+
* - 读:遍历文档树聚合各节点的 comments → 扁平线程视图(含所属 node)。
|
|
11
|
+
* - 写:按节点 per-thread `setProperty(threadId, thread)`,保留逐线程并发合并。
|
|
12
|
+
* - 位置:线程 offset 为元素局部坐标,渲染时经元素世界矩阵还原。
|
|
13
|
+
*/
|
|
14
|
+
function createCommentsStore(editor) {
|
|
15
|
+
const { camera, root } = editor;
|
|
16
|
+
const tick = ref(0);
|
|
17
|
+
function bump() {
|
|
18
|
+
tick.value++;
|
|
19
|
+
}
|
|
20
|
+
let detachUpdate;
|
|
21
|
+
function bindUpdate() {
|
|
22
|
+
detachUpdate?.();
|
|
23
|
+
const ydoc = root.value._yDoc;
|
|
24
|
+
ydoc.on("update", bump);
|
|
25
|
+
detachUpdate = () => ydoc.off("update", bump);
|
|
26
|
+
}
|
|
27
|
+
bindUpdate();
|
|
28
|
+
editor.on("docSet", bindUpdate);
|
|
29
|
+
onScopeDispose(() => {
|
|
30
|
+
detachUpdate?.();
|
|
31
|
+
editor.off("docSet", bindUpdate);
|
|
32
|
+
});
|
|
33
|
+
function collect(node, out) {
|
|
34
|
+
const threads = node?.comments?.toJSON?.();
|
|
35
|
+
if (threads?.length) threads.forEach((t) => {
|
|
36
|
+
out.push({
|
|
37
|
+
node,
|
|
38
|
+
id: t.id,
|
|
39
|
+
offset: t.offset ?? {
|
|
40
|
+
x: 0,
|
|
41
|
+
y: 0
|
|
42
|
+
},
|
|
43
|
+
resolved: Boolean(t.resolved),
|
|
44
|
+
messages: t.messages ?? []
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
node?.children?.forEach((child) => collect(child, out));
|
|
48
|
+
}
|
|
49
|
+
const threads = computed(() => {
|
|
50
|
+
tick.value;
|
|
51
|
+
const out = [];
|
|
52
|
+
collect(root.value, out);
|
|
53
|
+
return out.sort((a, b) => (a.messages[0]?.createdAt ?? 0) - (b.messages[0]?.createdAt ?? 0));
|
|
54
|
+
});
|
|
55
|
+
function author() {
|
|
56
|
+
const u = editor.presence?.localUser?.value;
|
|
57
|
+
return {
|
|
58
|
+
name: u?.name || "我",
|
|
59
|
+
color: u?.color || "#1C7ED6",
|
|
60
|
+
id: u?.id
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function threadsOf(node) {
|
|
64
|
+
return node?.comments?.toJSON?.() ?? [];
|
|
65
|
+
}
|
|
66
|
+
function addThread(node, offset, body) {
|
|
67
|
+
if (!node?.comments?.setProperty) return "";
|
|
68
|
+
const id = uid("t");
|
|
69
|
+
node.comments.setProperty(id, {
|
|
70
|
+
id,
|
|
71
|
+
offset,
|
|
72
|
+
resolved: false,
|
|
73
|
+
messages: [{
|
|
74
|
+
id: uid("m"),
|
|
75
|
+
author: author(),
|
|
76
|
+
body,
|
|
77
|
+
createdAt: Date.now()
|
|
78
|
+
}]
|
|
79
|
+
});
|
|
80
|
+
bump();
|
|
81
|
+
return id;
|
|
82
|
+
}
|
|
83
|
+
function reply(node, threadId, body) {
|
|
84
|
+
const t = threadsOf(node).find((x) => x.id === threadId);
|
|
85
|
+
if (!t || !node?.comments?.setProperty) return;
|
|
86
|
+
node.comments.setProperty(threadId, {
|
|
87
|
+
...t,
|
|
88
|
+
messages: [...t.messages ?? [], {
|
|
89
|
+
id: uid("m"),
|
|
90
|
+
author: author(),
|
|
91
|
+
body,
|
|
92
|
+
createdAt: Date.now()
|
|
93
|
+
}]
|
|
94
|
+
});
|
|
95
|
+
bump();
|
|
96
|
+
}
|
|
97
|
+
function resolve(node, threadId, resolved) {
|
|
98
|
+
const t = threadsOf(node).find((x) => x.id === threadId);
|
|
99
|
+
if (!t) return;
|
|
100
|
+
node.comments.setProperty(threadId, {
|
|
101
|
+
...t,
|
|
102
|
+
resolved
|
|
103
|
+
});
|
|
104
|
+
bump();
|
|
105
|
+
}
|
|
106
|
+
function remove(node, threadId) {
|
|
107
|
+
node.comments.setProperty(threadId, void 0);
|
|
108
|
+
bump();
|
|
109
|
+
}
|
|
110
|
+
function toScreen(p) {
|
|
111
|
+
const { zoom, position } = camera.value;
|
|
112
|
+
return {
|
|
113
|
+
x: p.x * zoom.x - position.x,
|
|
114
|
+
y: p.y * zoom.y - position.y
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function toWorld(p) {
|
|
118
|
+
const { zoom, position } = camera.value;
|
|
119
|
+
return {
|
|
120
|
+
x: (p.x + position.x) / zoom.x,
|
|
121
|
+
y: (p.y + position.y) / zoom.y
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
threads,
|
|
126
|
+
addThread,
|
|
127
|
+
reply,
|
|
128
|
+
resolve,
|
|
129
|
+
remove,
|
|
130
|
+
toScreen,
|
|
131
|
+
toWorld
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** 组件内读取评论数据层。 */
|
|
135
|
+
function useComments() {
|
|
136
|
+
const api = useEditor().comments;
|
|
137
|
+
if (!api) throw new Error("[mce] comments plugin is not installed");
|
|
138
|
+
return api;
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/CommentLayer.vue?vue&type=script&setup=true&lang.ts
|
|
142
|
+
var _hoisted_1 = { class: "m-comments" };
|
|
143
|
+
var _hoisted_2 = ["title", "onClick"];
|
|
144
|
+
var _hoisted_3 = {
|
|
145
|
+
key: 0,
|
|
146
|
+
class: "m-comments__pin-count"
|
|
147
|
+
};
|
|
148
|
+
var _hoisted_4 = ["title"];
|
|
149
|
+
var _hoisted_5 = { class: "m-comments__list" };
|
|
150
|
+
var _hoisted_6 = { class: "m-comments__bubble" };
|
|
151
|
+
var _hoisted_7 = { class: "m-comments__meta" };
|
|
152
|
+
var _hoisted_8 = { class: "m-comments__name" };
|
|
153
|
+
var _hoisted_9 = { class: "m-comments__time" };
|
|
154
|
+
var _hoisted_10 = { class: "m-comments__text" };
|
|
155
|
+
var _hoisted_11 = { class: "m-comments__reply" };
|
|
156
|
+
var _hoisted_12 = ["placeholder"];
|
|
157
|
+
var _hoisted_13 = { class: "m-comments__actions" };
|
|
158
|
+
var _hoisted_14 = ["title"];
|
|
159
|
+
var _hoisted_15 = ["placeholder", "onKeydown"];
|
|
160
|
+
var _hoisted_16 = { class: "m-comments__actions" };
|
|
161
|
+
var _hoisted_17 = ["disabled"];
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/CommentLayer.vue
|
|
164
|
+
var CommentLayer_default = /* @__PURE__ */ defineComponent({
|
|
165
|
+
__name: "CommentLayer",
|
|
166
|
+
setup(__props) {
|
|
167
|
+
const { activeTool, activateTool, drawboardAabb, getAabb, root, t } = useEditor();
|
|
168
|
+
const comments = useComments();
|
|
169
|
+
const isComment = computed(() => activeTool.value?.name === "comment");
|
|
170
|
+
const transformTick = ref(0);
|
|
171
|
+
let unsubs = [];
|
|
172
|
+
function resubscribe() {
|
|
173
|
+
unsubs.forEach((u) => u());
|
|
174
|
+
unsubs = [];
|
|
175
|
+
const seen = /* @__PURE__ */ new Set();
|
|
176
|
+
comments.threads.value.filter((th) => !th.resolved).forEach((th) => {
|
|
177
|
+
const node = th.node;
|
|
178
|
+
if (!node?.on || seen.has(node)) return;
|
|
179
|
+
seen.add(node);
|
|
180
|
+
const cb = () => {
|
|
181
|
+
transformTick.value++;
|
|
182
|
+
};
|
|
183
|
+
node.on("updateGlobalTransform", cb);
|
|
184
|
+
unsubs.push(() => node.off("updateGlobalTransform", cb));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
watch(() => comments.threads.value.filter((th) => !th.resolved).map((th) => th.node?.id).join(","), () => nextTick(resubscribe), { immediate: true });
|
|
188
|
+
onBeforeUnmount(() => unsubs.forEach((u) => u()));
|
|
189
|
+
/** 线程 → 画板屏幕坐标:所属元素局部 offset 经世界矩阵还原。读 transformTick 建立变换依赖。 */
|
|
190
|
+
function anchorScreen(thread) {
|
|
191
|
+
transformTick.value;
|
|
192
|
+
const node = thread.node;
|
|
193
|
+
if (!node?.toGlobal) return null;
|
|
194
|
+
return comments.toScreen(node.toGlobal(thread.offset));
|
|
195
|
+
}
|
|
196
|
+
/** 命中:世界坐标落在哪个元素内(取最上层),无则归到根;返回所属节点 + 其局部 offset。 */
|
|
197
|
+
function ownerAt(world) {
|
|
198
|
+
let hit;
|
|
199
|
+
const visit = (node) => {
|
|
200
|
+
node.children?.forEach((child) => {
|
|
201
|
+
const ab = getAabb(child);
|
|
202
|
+
if (ab.width && ab.height && ab.contains(world)) hit = child;
|
|
203
|
+
visit(child);
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
visit(root.value);
|
|
207
|
+
const node = hit ?? root.value;
|
|
208
|
+
const local = node.toLocal ? node.toLocal(world) : world;
|
|
209
|
+
return {
|
|
210
|
+
node,
|
|
211
|
+
offset: {
|
|
212
|
+
x: local.x,
|
|
213
|
+
y: local.y
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const pins = computed(() => comments.threads.value.filter((th) => !th.resolved).map((th) => ({
|
|
218
|
+
thread: th,
|
|
219
|
+
screen: anchorScreen(th)
|
|
220
|
+
})).filter((p) => Boolean(p.screen)));
|
|
221
|
+
const activeId = ref();
|
|
222
|
+
const activeThread = computed(() => comments.threads.value.find((th) => th.id === activeId.value));
|
|
223
|
+
const activePos = computed(() => activeThread.value ? anchorScreen(activeThread.value) : null);
|
|
224
|
+
const replyText = ref("");
|
|
225
|
+
const draft = ref();
|
|
226
|
+
const draftPos = computed(() => draft.value ? comments.toScreen(draft.value.world) : null);
|
|
227
|
+
const draftInput = ref();
|
|
228
|
+
function clientToDrawboard(e) {
|
|
229
|
+
return {
|
|
230
|
+
x: e.clientX - drawboardAabb.value.left,
|
|
231
|
+
y: e.clientY - drawboardAabb.value.top
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function onCatcherDown(e) {
|
|
235
|
+
if (activeId.value) {
|
|
236
|
+
activeId.value = void 0;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (draft.value) {
|
|
240
|
+
draft.value = void 0;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const world = comments.toWorld(clientToDrawboard(e));
|
|
244
|
+
const { node, offset } = ownerAt(world);
|
|
245
|
+
draft.value = {
|
|
246
|
+
node,
|
|
247
|
+
offset,
|
|
248
|
+
world,
|
|
249
|
+
text: ""
|
|
250
|
+
};
|
|
251
|
+
nextTick(() => draftInput.value?.focus());
|
|
252
|
+
}
|
|
253
|
+
function submitDraft() {
|
|
254
|
+
const d = draft.value;
|
|
255
|
+
if (!d || !d.text.trim()) return;
|
|
256
|
+
const id = comments.addThread(d.node, d.offset, d.text.trim());
|
|
257
|
+
draft.value = void 0;
|
|
258
|
+
if (id) activeId.value = id;
|
|
259
|
+
}
|
|
260
|
+
function openThread(id) {
|
|
261
|
+
draft.value = void 0;
|
|
262
|
+
activeId.value = activeId.value === id ? void 0 : id;
|
|
263
|
+
replyText.value = "";
|
|
264
|
+
}
|
|
265
|
+
function submitReply() {
|
|
266
|
+
const th = activeThread.value;
|
|
267
|
+
if (!th || !replyText.value.trim()) return;
|
|
268
|
+
comments.reply(th.node, th.id, replyText.value.trim());
|
|
269
|
+
replyText.value = "";
|
|
270
|
+
}
|
|
271
|
+
function toggleResolve() {
|
|
272
|
+
const th = activeThread.value;
|
|
273
|
+
if (!th) return;
|
|
274
|
+
comments.resolve(th.node, th.id, !th.resolved);
|
|
275
|
+
activeId.value = void 0;
|
|
276
|
+
}
|
|
277
|
+
function removeThread() {
|
|
278
|
+
const th = activeThread.value;
|
|
279
|
+
if (!th) return;
|
|
280
|
+
comments.remove(th.node, th.id);
|
|
281
|
+
activeId.value = void 0;
|
|
282
|
+
}
|
|
283
|
+
function initial(name) {
|
|
284
|
+
return (name || "?").trim().charAt(0).toUpperCase() || "?";
|
|
285
|
+
}
|
|
286
|
+
function fmtTime(ts) {
|
|
287
|
+
if (!ts) return "";
|
|
288
|
+
const d = new Date(ts);
|
|
289
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
290
|
+
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
291
|
+
}
|
|
292
|
+
function onKeydown(e) {
|
|
293
|
+
if (e.key !== "Escape") return;
|
|
294
|
+
if (draft.value) draft.value = void 0;
|
|
295
|
+
else if (activeId.value) activeId.value = void 0;
|
|
296
|
+
else if (isComment.value) activateTool(void 0);
|
|
297
|
+
else return;
|
|
298
|
+
e.stopPropagation();
|
|
299
|
+
}
|
|
300
|
+
onMounted(() => window.addEventListener("keydown", onKeydown, true));
|
|
301
|
+
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown, true));
|
|
302
|
+
return (_ctx, _cache) => {
|
|
303
|
+
return openBlock(), createElementBlock("div", _hoisted_1, [
|
|
304
|
+
isComment.value ? (openBlock(), createElementBlock("div", {
|
|
305
|
+
key: 0,
|
|
306
|
+
class: "m-comments__catcher",
|
|
307
|
+
onPointerdown: withModifiers(onCatcherDown, ["self"])
|
|
308
|
+
}, null, 32)) : createCommentVNode("", true),
|
|
309
|
+
(openBlock(true), createElementBlock(Fragment, null, renderList(pins.value, (p) => {
|
|
310
|
+
return openBlock(), createElementBlock("button", {
|
|
311
|
+
key: p.thread.id,
|
|
312
|
+
type: "button",
|
|
313
|
+
class: normalizeClass(["m-comments__pin", { "m-comments__pin--active": p.thread.id === activeId.value }]),
|
|
314
|
+
style: normalizeStyle({
|
|
315
|
+
left: `${p.screen.x}px`,
|
|
316
|
+
top: `${p.screen.y}px`,
|
|
317
|
+
backgroundColor: p.thread.messages[0]?.author?.color ?? "#1C7ED6"
|
|
318
|
+
}),
|
|
319
|
+
title: p.thread.messages[0]?.author?.name,
|
|
320
|
+
onPointerdown: _cache[0] || (_cache[0] = withModifiers(() => {}, ["stop"])),
|
|
321
|
+
onClick: withModifiers(($event) => openThread(p.thread.id), ["stop"])
|
|
322
|
+
}, [createTextVNode(toDisplayString(initial(p.thread.messages[0]?.author?.name)) + " ", 1), p.thread.messages.length > 1 ? (openBlock(), createElementBlock("span", _hoisted_3, toDisplayString(p.thread.messages.length), 1)) : createCommentVNode("", true)], 46, _hoisted_2);
|
|
323
|
+
}), 128)),
|
|
324
|
+
activeThread.value && activePos.value ? (openBlock(), createElementBlock("div", {
|
|
325
|
+
key: 1,
|
|
326
|
+
class: "m-comments__panel",
|
|
327
|
+
style: normalizeStyle({
|
|
328
|
+
left: `${activePos.value.x + 16}px`,
|
|
329
|
+
top: `${activePos.value.y}px`
|
|
330
|
+
}),
|
|
331
|
+
onPointerdown: _cache[4] || (_cache[4] = withModifiers(() => {}, ["stop"]))
|
|
332
|
+
}, [
|
|
333
|
+
createElementVNode("button", {
|
|
334
|
+
type: "button",
|
|
335
|
+
class: "m-comments__close",
|
|
336
|
+
title: unref(t)("comment:close"),
|
|
337
|
+
onClick: _cache[1] || (_cache[1] = ($event) => activeId.value = void 0)
|
|
338
|
+
}, [..._cache[8] || (_cache[8] = [createElementVNode("svg", {
|
|
339
|
+
width: "14",
|
|
340
|
+
height: "14",
|
|
341
|
+
viewBox: "0 0 24 24",
|
|
342
|
+
"aria-hidden": "true"
|
|
343
|
+
}, [createElementVNode("path", {
|
|
344
|
+
d: "M6 6L18 18M18 6L6 18",
|
|
345
|
+
stroke: "currentColor",
|
|
346
|
+
"stroke-width": "2.2",
|
|
347
|
+
"stroke-linecap": "round"
|
|
348
|
+
})], -1)])], 8, _hoisted_4),
|
|
349
|
+
createElementVNode("div", _hoisted_5, [(openBlock(true), createElementBlock(Fragment, null, renderList(activeThread.value.messages, (c) => {
|
|
350
|
+
return openBlock(), createElementBlock("div", {
|
|
351
|
+
key: c.id,
|
|
352
|
+
class: "m-comments__item"
|
|
353
|
+
}, [createElementVNode("span", {
|
|
354
|
+
class: "m-comments__avatar",
|
|
355
|
+
style: normalizeStyle({ backgroundColor: c.author?.color ?? "#1C7ED6" })
|
|
356
|
+
}, toDisplayString(initial(c.author?.name)), 5), createElementVNode("div", _hoisted_6, [createElementVNode("div", _hoisted_7, [createElementVNode("span", _hoisted_8, toDisplayString(c.author?.name), 1), createElementVNode("span", _hoisted_9, toDisplayString(fmtTime(c.createdAt)), 1)]), createElementVNode("div", _hoisted_10, toDisplayString(c.body), 1)])]);
|
|
357
|
+
}), 128))]),
|
|
358
|
+
createElementVNode("div", _hoisted_11, [withDirectives(createElementVNode("input", {
|
|
359
|
+
"onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => replyText.value = $event),
|
|
360
|
+
class: "m-comments__input",
|
|
361
|
+
placeholder: unref(t)("comment:reply"),
|
|
362
|
+
onKeydown: withKeys(submitReply, ["enter"]),
|
|
363
|
+
onPointerdown: _cache[3] || (_cache[3] = withModifiers(() => {}, ["stop"]))
|
|
364
|
+
}, null, 40, _hoisted_12), [[vModelText, replyText.value]])]),
|
|
365
|
+
createElementVNode("div", _hoisted_13, [createElementVNode("button", {
|
|
366
|
+
type: "button",
|
|
367
|
+
class: "m-comments__btn",
|
|
368
|
+
onClick: removeThread
|
|
369
|
+
}, toDisplayString(unref(t)("comment:delete")), 1), createElementVNode("button", {
|
|
370
|
+
type: "button",
|
|
371
|
+
class: "m-comments__btn m-comments__btn--primary",
|
|
372
|
+
onClick: toggleResolve
|
|
373
|
+
}, toDisplayString(activeThread.value.resolved ? unref(t)("comment:reopen") : unref(t)("comment:resolve")), 1)])
|
|
374
|
+
], 36)) : createCommentVNode("", true),
|
|
375
|
+
draft.value && draftPos.value ? (openBlock(), createElementBlock("div", {
|
|
376
|
+
key: 2,
|
|
377
|
+
class: "m-comments__panel m-comments__panel--draft",
|
|
378
|
+
style: normalizeStyle({
|
|
379
|
+
left: `${draftPos.value.x + 16}px`,
|
|
380
|
+
top: `${draftPos.value.y}px`
|
|
381
|
+
}),
|
|
382
|
+
onPointerdown: _cache[7] || (_cache[7] = withModifiers(() => {}, ["stop"]))
|
|
383
|
+
}, [
|
|
384
|
+
createElementVNode("button", {
|
|
385
|
+
type: "button",
|
|
386
|
+
class: "m-comments__close",
|
|
387
|
+
title: unref(t)("comment:close"),
|
|
388
|
+
onClick: _cache[5] || (_cache[5] = ($event) => draft.value = void 0)
|
|
389
|
+
}, [..._cache[9] || (_cache[9] = [createElementVNode("svg", {
|
|
390
|
+
width: "14",
|
|
391
|
+
height: "14",
|
|
392
|
+
viewBox: "0 0 24 24",
|
|
393
|
+
"aria-hidden": "true"
|
|
394
|
+
}, [createElementVNode("path", {
|
|
395
|
+
d: "M6 6L18 18M18 6L6 18",
|
|
396
|
+
stroke: "currentColor",
|
|
397
|
+
"stroke-width": "2.2",
|
|
398
|
+
"stroke-linecap": "round"
|
|
399
|
+
})], -1)])], 8, _hoisted_14),
|
|
400
|
+
withDirectives(createElementVNode("textarea", {
|
|
401
|
+
ref_key: "draftInput",
|
|
402
|
+
ref: draftInput,
|
|
403
|
+
"onUpdate:modelValue": _cache[6] || (_cache[6] = ($event) => draft.value.text = $event),
|
|
404
|
+
class: "m-comments__textarea",
|
|
405
|
+
placeholder: unref(t)("comment:placeholder"),
|
|
406
|
+
onKeydown: withKeys(withModifiers(submitDraft, ["exact", "prevent"]), ["enter"])
|
|
407
|
+
}, null, 40, _hoisted_15), [[vModelText, draft.value.text]]),
|
|
408
|
+
createElementVNode("div", _hoisted_16, [createElementVNode("button", {
|
|
409
|
+
type: "button",
|
|
410
|
+
class: "m-comments__btn m-comments__btn--primary",
|
|
411
|
+
disabled: !draft.value.text.trim(),
|
|
412
|
+
onClick: submitDraft
|
|
413
|
+
}, toDisplayString(unref(t)("comment")), 9, _hoisted_17)])
|
|
414
|
+
], 36)) : createCommentVNode("", true)
|
|
415
|
+
]);
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/plugin.ts
|
|
421
|
+
function plugin() {
|
|
422
|
+
return definePlugin((editor) => {
|
|
423
|
+
const { activeTool, activateTool } = editor;
|
|
424
|
+
function isCommentActive() {
|
|
425
|
+
return activeTool.value?.name === "comment";
|
|
426
|
+
}
|
|
427
|
+
function toggleCommentMode() {
|
|
428
|
+
activateTool(isCommentActive() ? void 0 : "comment");
|
|
429
|
+
}
|
|
430
|
+
editor.registerIcon("comment", "M9,22A1,1 0 0,1 8,21V18H4A2,2 0 0,1 2,16V4C2,2.89 2.9,2 4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H13.9L10.2,21.71C10,21.9 9.75,22 9.5,22V22H9M10,16V19.08L13,16H20V4H4V16H10Z");
|
|
431
|
+
editor.registerToolbeltItem({
|
|
432
|
+
key: "comment",
|
|
433
|
+
isActive: isCommentActive,
|
|
434
|
+
handle: toggleCommentMode
|
|
435
|
+
});
|
|
436
|
+
return {
|
|
437
|
+
name: "mce:comments",
|
|
438
|
+
tools: [{
|
|
439
|
+
name: "comment",
|
|
440
|
+
handle: () => ({})
|
|
441
|
+
}],
|
|
442
|
+
commands: [{
|
|
443
|
+
command: "toggleCommentMode",
|
|
444
|
+
handle: toggleCommentMode
|
|
445
|
+
}],
|
|
446
|
+
hotkeys: [{
|
|
447
|
+
command: "activateTool:comment",
|
|
448
|
+
key: "C"
|
|
449
|
+
}],
|
|
450
|
+
components: [{
|
|
451
|
+
type: "overlay",
|
|
452
|
+
component: CommentLayer_default,
|
|
453
|
+
order: "before"
|
|
454
|
+
}],
|
|
455
|
+
setup: () => {
|
|
456
|
+
editor.comments = createCommentsStore(editor);
|
|
457
|
+
},
|
|
458
|
+
messages: {
|
|
459
|
+
en: {
|
|
460
|
+
"comment": "Comment",
|
|
461
|
+
"comment:placeholder": "Add a comment",
|
|
462
|
+
"comment:reply": "Reply",
|
|
463
|
+
"comment:resolve": "Resolve",
|
|
464
|
+
"comment:reopen": "Reopen",
|
|
465
|
+
"comment:delete": "Delete",
|
|
466
|
+
"comment:close": "Close"
|
|
467
|
+
},
|
|
468
|
+
zhHans: {
|
|
469
|
+
"comment": "评论",
|
|
470
|
+
"comment:placeholder": "添加评论",
|
|
471
|
+
"comment:reply": "回复",
|
|
472
|
+
"comment:resolve": "解决",
|
|
473
|
+
"comment:reopen": "重新打开",
|
|
474
|
+
"comment:delete": "删除",
|
|
475
|
+
"comment:close": "关闭"
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/index.ts
|
|
483
|
+
var src_default = plugin;
|
|
484
|
+
//#endregion
|
|
485
|
+
export { createCommentsStore, src_default as default, plugin, useComments };
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CommentsApi } from './useComments';
|
|
2
|
+
declare global {
|
|
3
|
+
namespace Mce {
|
|
4
|
+
interface Editor {
|
|
5
|
+
/** 评论数据层(线程读写 + 坐标换算)。 */
|
|
6
|
+
comments: CommentsApi;
|
|
7
|
+
}
|
|
8
|
+
interface Tools {
|
|
9
|
+
/** 评论工具(与移动 / 框架 / 形状等单选互斥;激活后点画布放置评论)。 */
|
|
10
|
+
comment: [];
|
|
11
|
+
}
|
|
12
|
+
interface Commands {
|
|
13
|
+
/** 切换评论工具(激活 ⇄ 取消)。 */
|
|
14
|
+
toggleCommentMode: () => void;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export declare function plugin(): import("mce").Plugin;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Editor } from 'mce';
|
|
2
|
+
import type { ComputedRef } from 'vue';
|
|
3
|
+
/** 评论作者。 */
|
|
4
|
+
export interface CommentAuthor {
|
|
5
|
+
id?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
color?: string;
|
|
8
|
+
initials?: string;
|
|
9
|
+
}
|
|
10
|
+
/** 线程中的单条消息。 */
|
|
11
|
+
export interface CommentMessage {
|
|
12
|
+
id: string;
|
|
13
|
+
author?: CommentAuthor;
|
|
14
|
+
body: string;
|
|
15
|
+
createdAt?: number;
|
|
16
|
+
}
|
|
17
|
+
/** 聚合后的线程视图:携带所属元素节点 + idoc 线程字段。 */
|
|
18
|
+
export interface CommentThreadView {
|
|
19
|
+
/** 所属元素节点(运行时 Element2D)。 */
|
|
20
|
+
node: any;
|
|
21
|
+
id: string;
|
|
22
|
+
/** 相对所属元素原点的偏移。 */
|
|
23
|
+
offset: {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
};
|
|
27
|
+
resolved: boolean;
|
|
28
|
+
messages: CommentMessage[];
|
|
29
|
+
}
|
|
30
|
+
export interface CommentsApi {
|
|
31
|
+
/** 全树聚合的线程(按创建时间升序)。 */
|
|
32
|
+
threads: ComputedRef<CommentThreadView[]>;
|
|
33
|
+
/** 在某元素上新建线程,offset 为相对该元素原点的局部坐标,返回线程 id。 */
|
|
34
|
+
addThread: (node: any, offset: {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
}, body: string) => string;
|
|
38
|
+
/** 向线程追加回复。 */
|
|
39
|
+
reply: (node: any, threadId: string, body: string) => void;
|
|
40
|
+
/** 解决 / 取消解决。 */
|
|
41
|
+
resolve: (node: any, threadId: string, resolved: boolean) => void;
|
|
42
|
+
/** 删除线程。 */
|
|
43
|
+
remove: (node: any, threadId: string) => void;
|
|
44
|
+
/** 全局画布坐标 → 画板(屏幕)像素。 */
|
|
45
|
+
toScreen: (p: {
|
|
46
|
+
x: number;
|
|
47
|
+
y: number;
|
|
48
|
+
}) => {
|
|
49
|
+
x: number;
|
|
50
|
+
y: number;
|
|
51
|
+
};
|
|
52
|
+
/** 画板(屏幕)像素 → 全局画布坐标。 */
|
|
53
|
+
toWorld: (p: {
|
|
54
|
+
x: number;
|
|
55
|
+
y: number;
|
|
56
|
+
}) => {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 评论数据层:评论作为元素能力存于 `element.comments`(idoc CommentThread[]),
|
|
63
|
+
* 经 modern-canvas 的 Element2DComments + mce CRDT 逐线程同步。
|
|
64
|
+
* - 读:遍历文档树聚合各节点的 comments → 扁平线程视图(含所属 node)。
|
|
65
|
+
* - 写:按节点 per-thread `setProperty(threadId, thread)`,保留逐线程并发合并。
|
|
66
|
+
* - 位置:线程 offset 为元素局部坐标,渲染时经元素世界矩阵还原。
|
|
67
|
+
*/
|
|
68
|
+
export declare function createCommentsStore(editor: Editor): CommentsApi;
|
|
69
|
+
/** 组件内读取评论数据层。 */
|
|
70
|
+
export declare function useComments(): CommentsApi;
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mce/comments",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.24.4",
|
|
5
|
+
"description": "comments plugin for mce",
|
|
6
|
+
"author": "wxm",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/qq15725/mce",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/qq15725/mce.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/qq15725/mce/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mce",
|
|
18
|
+
"comments",
|
|
19
|
+
"plugin"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": true,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js",
|
|
26
|
+
"require": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./*.mjs": "./*.js",
|
|
29
|
+
"./*": "./*"
|
|
30
|
+
},
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"module": "./dist/index.js",
|
|
33
|
+
"browser": "./dist/index.js",
|
|
34
|
+
"typings": "dist/index.d.ts",
|
|
35
|
+
"types": "dist/index.d.ts",
|
|
36
|
+
"typesVersions": {
|
|
37
|
+
"*": {
|
|
38
|
+
"*": [
|
|
39
|
+
"*",
|
|
40
|
+
"dist/*",
|
|
41
|
+
"dist/*.d.ts"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist"
|
|
47
|
+
],
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@vitejs/plugin-vue": "^6.0.7",
|
|
50
|
+
"mce": "0.24.4"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"mce": "^0",
|
|
54
|
+
"vue": "^3.5.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build:code": "vite build",
|
|
58
|
+
"build:tsc": "NODE_ENV=production vue-tsc --emitDeclarationOnly --project tsconfig.json",
|
|
59
|
+
"build": "pnpm build:code && pnpm build:tsc",
|
|
60
|
+
"lint": "eslint src",
|
|
61
|
+
"typecheck": "vue-tsc --noEmit --project tsconfig.json"
|
|
62
|
+
}
|
|
63
|
+
}
|