@tldraw/commenting 5.3.0-next.7654e7ac2a02 → 5.3.0-next.aefb8773a7eb
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/commenting.css +34 -28
- package/dist-cjs/canvas/comment-tool.js +12 -6
- package/dist-cjs/canvas/comment-tool.js.map +2 -2
- package/dist-cjs/canvas/comments-filter-menu.js +1 -1
- package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +4 -1
- package/dist-cjs/canvas/comments-overlay.js.map +2 -2
- package/dist-cjs/canvas/pending-composer.js +1 -0
- package/dist-cjs/canvas/pending-composer.js.map +2 -2
- package/dist-cjs/canvas/thread-pin.js +1 -1
- package/dist-cjs/canvas/thread-pin.js.map +2 -2
- package/dist-cjs/index.d.ts +4 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/ui/format-time.js +1 -1
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/icons.js +52 -29
- package/dist-cjs/ui/icons.js.map +2 -2
- package/dist-esm/canvas/comment-tool.mjs +12 -6
- package/dist-esm/canvas/comment-tool.mjs.map +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs +2 -2
- package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +4 -1
- package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
- package/dist-esm/canvas/pending-composer.mjs +1 -0
- package/dist-esm/canvas/pending-composer.mjs.map +2 -2
- package/dist-esm/canvas/thread-pin.mjs +1 -1
- package/dist-esm/canvas/thread-pin.mjs.map +2 -2
- package/dist-esm/index.d.mts +4 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/ui/format-time.mjs +1 -1
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/icons.mjs +53 -30
- package/dist-esm/ui/icons.mjs.map +2 -2
- package/package.json +7 -6
- package/src/canvas/comment-tool.test.ts +109 -4
- package/src/canvas/comment-tool.tsx +24 -8
- package/src/canvas/comments-filter-menu.tsx +2 -2
- package/src/canvas/comments-overlay.tsx +7 -2
- package/src/canvas/pending-composer.tsx +4 -0
- package/src/canvas/thread-pin.tsx +3 -2
- package/src/ui/comments.css +34 -28
- package/src/ui/format-time.test.ts +3 -3
- package/src/ui/format-time.ts +2 -2
- package/src/ui/icons.tsx +43 -21
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/commenting",
|
|
3
3
|
"description": "tldraw commenting UI components.",
|
|
4
|
-
"version": "5.3.0-next.
|
|
4
|
+
"version": "5.3.0-next.aefb8773a7eb",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -38,11 +38,12 @@
|
|
|
38
38
|
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@tldraw/driver": "5.3.0-next.
|
|
41
|
+
"@tldraw/driver": "5.3.0-next.aefb8773a7eb",
|
|
42
42
|
"@types/react": "^19.2.7",
|
|
43
43
|
"@types/react-dom": "^19.2.3",
|
|
44
44
|
"react": "^19.2.1",
|
|
45
|
-
"react-dom": "^19.2.1"
|
|
45
|
+
"react-dom": "^19.2.1",
|
|
46
|
+
"vitest": "^4.1.7"
|
|
46
47
|
},
|
|
47
48
|
"peerDependencies": {
|
|
48
49
|
"react": "^18.2.0 || ^19.2.1",
|
|
@@ -55,9 +56,9 @@
|
|
|
55
56
|
"@tiptap/core": "^3.12.1",
|
|
56
57
|
"@tiptap/pm": "^3.12.1",
|
|
57
58
|
"@tiptap/react": "^3.12.1",
|
|
58
|
-
"@tldraw/mentions": "5.3.0-next.
|
|
59
|
-
"@tldraw/utils": "5.3.0-next.
|
|
60
|
-
"tldraw": "5.3.0-next.
|
|
59
|
+
"@tldraw/mentions": "5.3.0-next.aefb8773a7eb",
|
|
60
|
+
"@tldraw/utils": "5.3.0-next.aefb8773a7eb",
|
|
61
|
+
"tldraw": "5.3.0-next.aefb8773a7eb"
|
|
61
62
|
},
|
|
62
63
|
"module": "dist-esm/index.mjs",
|
|
63
64
|
"source": "src/index.ts",
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'tldraw'
|
|
14
14
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
15
15
|
import { CommentTool } from './comment-tool'
|
|
16
|
+
import { commentsSidebarOpen, pendingComment, regionDraft } from './state'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Pointer-driven tests for the comment tool's placement affordances. These need a real editor
|
|
@@ -90,14 +91,80 @@ describe('comment tool placement hints', () => {
|
|
|
90
91
|
driver.pointerMove(150, 125)
|
|
91
92
|
expect(editor.getHintingShapeIds()).toEqual([id])
|
|
92
93
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
driver.pointerUp(150, 125)
|
|
96
|
-
expect(editor.isIn('select')).toBe(true)
|
|
94
|
+
// Select owns its own hover state, so the comment hint leaves with the tool.
|
|
95
|
+
editor.setCurrentTool('select')
|
|
97
96
|
expect(editor.getHintingShapeIds()).toEqual([])
|
|
98
97
|
})
|
|
99
98
|
})
|
|
100
99
|
|
|
100
|
+
describe('comment tool placement lifecycle', () => {
|
|
101
|
+
let driver: Driver
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
driver = makeEditor()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('stays in the tool with the composer open after placing', () => {
|
|
108
|
+
editor.setCurrentTool('comment')
|
|
109
|
+
driver.pointerDown(400, 300)
|
|
110
|
+
driver.pointerUp(400, 300)
|
|
111
|
+
// Placement opens the composer but the interaction isn't over — the tool holds on until
|
|
112
|
+
// the comment is posted or dismissed, so the surrounding UI stays stable.
|
|
113
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
114
|
+
expect(pendingComment.get(editor)).toEqual({
|
|
115
|
+
anchor: { type: 'point', x: 400, y: 300 },
|
|
116
|
+
point: { x: 400, y: 300 },
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('re-places the composer on a second click', () => {
|
|
121
|
+
editor.setCurrentTool('comment')
|
|
122
|
+
driver.pointerDown(400, 300)
|
|
123
|
+
driver.pointerUp(400, 300)
|
|
124
|
+
driver.pointerDown(200, 200)
|
|
125
|
+
driver.pointerUp(200, 200)
|
|
126
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
127
|
+
expect(pendingComment.get(editor)).toEqual({
|
|
128
|
+
anchor: { type: 'point', x: 200, y: 200 },
|
|
129
|
+
point: { x: 200, y: 200 },
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('drops the pending composer when the tool exits', () => {
|
|
134
|
+
editor.setCurrentTool('comment')
|
|
135
|
+
driver.pointerDown(400, 300)
|
|
136
|
+
driver.pointerUp(400, 300)
|
|
137
|
+
expect(pendingComment.get(editor)).not.toBeNull()
|
|
138
|
+
|
|
139
|
+
// A direct tool switch (toolbar, shortcut) abandons the draft composer — it belongs to
|
|
140
|
+
// the tool. The text itself survives in the comment draft store.
|
|
141
|
+
editor.setCurrentTool('select')
|
|
142
|
+
expect(pendingComment.get(editor)).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('closes the comments sidebar for the whole interaction', () => {
|
|
146
|
+
commentsSidebarOpen.set(editor, true)
|
|
147
|
+
editor.setCurrentTool('comment')
|
|
148
|
+
expect(commentsSidebarOpen.get(editor)).toBe(false)
|
|
149
|
+
|
|
150
|
+
// ...and placing doesn't reopen it: the tool (and the closed sidebar) hold through the
|
|
151
|
+
// composer. Reopening is the host's button, never a side effect of leaving the tool.
|
|
152
|
+
driver.pointerDown(400, 300)
|
|
153
|
+
driver.pointerUp(400, 300)
|
|
154
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
155
|
+
expect(commentsSidebarOpen.get(editor)).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('escape leaves the tool for select and drops the draft', () => {
|
|
159
|
+
editor.setCurrentTool('comment')
|
|
160
|
+
driver.pointerDown(400, 300)
|
|
161
|
+
driver.pointerUp(400, 300)
|
|
162
|
+
editor.cancel()
|
|
163
|
+
expect(editor.isIn('select')).toBe(true)
|
|
164
|
+
expect(pendingComment.get(editor)).toBeNull()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
101
168
|
describe('comment tool placement hints with regions enabled', () => {
|
|
102
169
|
it('clears the hint when a drag becomes a region', () => {
|
|
103
170
|
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
|
@@ -113,6 +180,44 @@ describe('comment tool placement hints with regions enabled', () => {
|
|
|
113
180
|
expect(editor.isIn('comment.dragging')).toBe(true)
|
|
114
181
|
expect(editor.getHintingShapeIds()).toEqual([])
|
|
115
182
|
})
|
|
183
|
+
|
|
184
|
+
it('stays in the tool with the composer open after a region drag', () => {
|
|
185
|
+
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
|
186
|
+
editor.setCurrentTool('comment')
|
|
187
|
+
driver.pointerDown(100, 100)
|
|
188
|
+
driver.pointerMove(200, 180)
|
|
189
|
+
driver.pointerUp(200, 180)
|
|
190
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
191
|
+
expect(pendingComment.get(editor)).toEqual({
|
|
192
|
+
anchor: { type: 'region', x: 100, y: 100, w: 100, h: 80, pinX: 1, pinY: 1 },
|
|
193
|
+
point: { x: 200, y: 180 },
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('does not hint a shape under the pin corner of a placed region', () => {
|
|
198
|
+
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
|
199
|
+
makeShape(100, 100)
|
|
200
|
+
editor.setCurrentTool('comment')
|
|
201
|
+
// Release on top of the shape: a region anchors to its rectangle, so the outline that
|
|
202
|
+
// normally previews a shape anchor would be a lie here.
|
|
203
|
+
driver.pointerDown(50, 50)
|
|
204
|
+
driver.pointerMove(150, 125)
|
|
205
|
+
driver.pointerUp(150, 125)
|
|
206
|
+
expect(editor.isIn('comment.idle')).toBe(true)
|
|
207
|
+
expect(editor.getHintingShapeIds()).toEqual([])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('drops the region draft when the tool exits mid-drag', () => {
|
|
211
|
+
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
|
212
|
+
editor.setCurrentTool('comment')
|
|
213
|
+
driver.pointerDown(100, 100)
|
|
214
|
+
driver.pointerMove(200, 180)
|
|
215
|
+
expect(regionDraft.get(editor)).not.toBeNull()
|
|
216
|
+
|
|
217
|
+
// A direct tool switch (shortcut, toolbar) mid-drag must not strand the drawn rectangle.
|
|
218
|
+
editor.setCurrentTool('select')
|
|
219
|
+
expect(regionDraft.get(editor)).toBeNull()
|
|
220
|
+
})
|
|
116
221
|
})
|
|
117
222
|
|
|
118
223
|
describe('comment tool anchoring to frames', () => {
|
|
@@ -52,7 +52,9 @@ export function regionBetween(a: VecLike, b: VecLike): BoxModel {
|
|
|
52
52
|
* The comment tool. Pressing down opens the comment composer at the pointer and it follows until
|
|
53
53
|
* release — like placing a sticky note — settling on a point, or on a shape when released over one.
|
|
54
54
|
* With region comments enabled, dragging past the threshold draws a region rectangle instead.
|
|
55
|
-
* Placement only opens a composer; the records are created when the comment is posted.
|
|
55
|
+
* Placement only opens a composer; the records are created when the comment is posted. The tool
|
|
56
|
+
* stays active while the composer is open — posting returns to select, and clicking elsewhere
|
|
57
|
+
* re-places the composer.
|
|
56
58
|
* @public
|
|
57
59
|
*/
|
|
58
60
|
export class CommentTool extends StateNode {
|
|
@@ -98,8 +100,11 @@ export class CommentTool extends StateNode {
|
|
|
98
100
|
|
|
99
101
|
override onExit() {
|
|
100
102
|
// Drop the hover hint painted while pointing at shapes (see CommentIdle). The cursor resets
|
|
101
|
-
// when the next tool takes over.
|
|
103
|
+
// when the next tool takes over. The draft composer and region draft belong to the tool, so
|
|
104
|
+
// they leave with it; the draft's text survives in the comment draft store.
|
|
102
105
|
this.editor.setHintingShapes([])
|
|
106
|
+
pendingComment.set(this.editor, null)
|
|
107
|
+
regionDraft.set(this.editor, null)
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
// Escape leaves the tool, like the built-in tools (the editor dispatches `cancel` on Escape).
|
|
@@ -122,7 +127,14 @@ class CommentIdle extends StateNode {
|
|
|
122
127
|
override onEnter() {
|
|
123
128
|
// Back to hovering: restore the pin cursor (a prior placing state may have hidden it).
|
|
124
129
|
this.editor.setCursor({ type: 'comment', rotation: 0 })
|
|
125
|
-
|
|
130
|
+
// With a region composer open, a shape outline under its pin corner would imply a shape
|
|
131
|
+
// anchor the region will never use — and it would stick, since moves over the composer
|
|
132
|
+
// never reach the canvas. Point placements keep the hint: it previews the next click.
|
|
133
|
+
if (pendingComment.get(this.editor)?.anchor.type === 'region') {
|
|
134
|
+
this.editor.setHintingShapes([])
|
|
135
|
+
} else {
|
|
136
|
+
updateAnchorHint(this.editor)
|
|
137
|
+
}
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
override onPointerMove() {
|
|
@@ -185,7 +197,10 @@ class CommentPointing extends StateNode {
|
|
|
185
197
|
)
|
|
186
198
|
: { type: 'point', x: point.x, y: point.y }
|
|
187
199
|
pendingComment.set(editor, { anchor, point: { x: point.x, y: point.y } })
|
|
188
|
-
|
|
200
|
+
// Stay in the tool while the composer is open — the interaction isn't over until the
|
|
201
|
+
// comment is posted or dismissed, and staying keeps the surrounding UI (style panel,
|
|
202
|
+
// sidebar) from churning mid-placement.
|
|
203
|
+
this.parent.transition('idle')
|
|
189
204
|
}
|
|
190
205
|
|
|
191
206
|
override onCancel() {
|
|
@@ -196,9 +211,9 @@ class CommentPointing extends StateNode {
|
|
|
196
211
|
this.cancel()
|
|
197
212
|
}
|
|
198
213
|
|
|
199
|
-
// Abandon the follow composer if placement is interrupted (Escape, focus loss, etc.).
|
|
214
|
+
// Abandon the follow composer if placement is interrupted (Escape, focus loss, etc.). The
|
|
215
|
+
// tool's onExit drops the draft.
|
|
200
216
|
private cancel() {
|
|
201
|
-
pendingComment.set(this.editor, null)
|
|
202
217
|
this.editor.setCurrentTool('select')
|
|
203
218
|
}
|
|
204
219
|
}
|
|
@@ -233,7 +248,8 @@ class CommentDragging extends StateNode {
|
|
|
233
248
|
anchor: { type: 'region', ...region, pinX: pin.x, pinY: pin.y },
|
|
234
249
|
point: regionPinPoint(region, pin),
|
|
235
250
|
})
|
|
236
|
-
|
|
251
|
+
// Same as the point placement: the tool stays active while the composer is open.
|
|
252
|
+
this.parent.transition('idle')
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
override onCancel() {
|
|
@@ -252,8 +268,8 @@ class CommentDragging extends StateNode {
|
|
|
252
268
|
)
|
|
253
269
|
}
|
|
254
270
|
|
|
271
|
+
// The tool's onExit drops the region draft.
|
|
255
272
|
private cancel() {
|
|
256
|
-
regionDraft.set(this.editor, null)
|
|
257
273
|
this.editor.setCurrentTool('select')
|
|
258
274
|
}
|
|
259
275
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
useTranslation,
|
|
11
11
|
useValue,
|
|
12
12
|
} from 'tldraw'
|
|
13
|
-
import {
|
|
13
|
+
import { MoreMenuIcon } from '../ui/icons'
|
|
14
14
|
import { SidebarFilters } from './sidebar-filters'
|
|
15
15
|
import { sidebarFilters } from './state'
|
|
16
16
|
|
|
@@ -44,7 +44,7 @@ export function CommentsFilterMenu({
|
|
|
44
44
|
title={msg('comments.filter')}
|
|
45
45
|
className="tlui-cmt-header-btn"
|
|
46
46
|
>
|
|
47
|
-
<
|
|
47
|
+
<MoreMenuIcon />
|
|
48
48
|
</TldrawUiButton>
|
|
49
49
|
</TldrawUiDropdownMenuTrigger>
|
|
50
50
|
{/* No tlui-cmt-menu here: the rows are tldraw menu items, which carry their own insets —
|
|
@@ -68,11 +68,16 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
68
68
|
const pending = usePendingComment()
|
|
69
69
|
const canComment = useCanComment(props.currentUserId)
|
|
70
70
|
// Nothing renders a pending comment when composing is blocked and there's no fallback slot, and
|
|
71
|
-
// the dismiss handlers live inside PendingComposer — so clear the atom rather than strand it
|
|
71
|
+
// the dismiss handlers live inside PendingComposer — so clear the atom rather than strand it,
|
|
72
|
+
// and leave the tool too: it holds while a composer is open, so staying would turn every click
|
|
73
|
+
// into a silently swallowed placement.
|
|
72
74
|
const canRenderComposer = canComment || options.components.ComposerFallback != null
|
|
73
75
|
const showPendingComposer = pending != null && canRenderComposer
|
|
74
76
|
useEffect(() => {
|
|
75
|
-
if (pending && !showPendingComposer)
|
|
77
|
+
if (pending && !showPendingComposer) {
|
|
78
|
+
pendingComment.set(editor, null)
|
|
79
|
+
if (editor.isIn('comment')) editor.setCurrentTool('select')
|
|
80
|
+
}
|
|
76
81
|
}, [editor, pending, showPendingComposer])
|
|
77
82
|
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
|
78
83
|
// Matches the sidebar's `showResolved` filter. The open thread stays in — resolving from its own
|
|
@@ -107,6 +107,10 @@ export function PendingComposer({
|
|
|
107
107
|
setText(EMPTY_COMMENT)
|
|
108
108
|
clearCommentDraft(NEW_COMMENT_DRAFT)
|
|
109
109
|
pendingComment.set(editor, null)
|
|
110
|
+
// Posting ends the placement interaction — hand the comment tool back to select. Only from
|
|
111
|
+
// the settled idle state: Enter can land while a fresh press is mid-gesture (the composer
|
|
112
|
+
// trails the pointer), and switching tools under a held pointer would strand the gesture.
|
|
113
|
+
if (editor.isIn('comment.idle')) editor.setCurrentTool('select')
|
|
110
114
|
// The host's callback is its own operation, not part of the post's history scope. It runs
|
|
111
115
|
// last so a throwing host can't strand the composer holding a draft of a posted comment.
|
|
112
116
|
onPostComment?.(comment)
|
|
@@ -305,9 +305,10 @@ export const ThreadPin = memo(function ThreadPin({
|
|
|
305
305
|
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
|
306
306
|
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
|
307
307
|
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
|
308
|
-
// The marker anchors bottom-left, so step half its
|
|
308
|
+
// The marker anchors bottom-left, so step half its 28px size (--tlui-cmt-pin-size) left and
|
|
309
|
+
// down (screen px), matching the draft composer's --region centring on the same corner.
|
|
309
310
|
const renderPoint = isRegion
|
|
310
|
-
? { x: renderPointBase.x -
|
|
311
|
+
? { x: renderPointBase.x - 14, y: renderPointBase.y + 14 }
|
|
311
312
|
: renderPointBase
|
|
312
313
|
|
|
313
314
|
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
package/src/ui/comments.css
CHANGED
|
@@ -84,6 +84,15 @@
|
|
|
84
84
|
overflow-wrap: anywhere;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/* A comment body is read-only but its text must stay selectable so it can be copied. tldraw's
|
|
88
|
+
container sets `user-select: none` on every descendant (`.tl-container *`) to keep canvas gestures
|
|
89
|
+
from selecting UI text, so the body and its rendered children have to opt back in. */
|
|
90
|
+
.tlui-cmt-text,
|
|
91
|
+
.tlui-cmt-text * {
|
|
92
|
+
-webkit-user-select: text;
|
|
93
|
+
user-select: text;
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
.tlui-cmt-edited {
|
|
88
97
|
font-style: italic;
|
|
89
98
|
}
|
|
@@ -777,7 +786,7 @@ button.tlui-cmt-reaction--active:hover {
|
|
|
777
786
|
align-items: center;
|
|
778
787
|
justify-content: space-between;
|
|
779
788
|
gap: 8px;
|
|
780
|
-
padding:
|
|
789
|
+
padding: 2px 2px 2px 14px;
|
|
781
790
|
border-bottom: 1px solid var(--tl-color-divider);
|
|
782
791
|
}
|
|
783
792
|
.tlui-cmt-list__header-title {
|
|
@@ -786,41 +795,28 @@ button.tlui-cmt-reaction--active:hover {
|
|
|
786
795
|
color: var(--tl-color-text-1);
|
|
787
796
|
}
|
|
788
797
|
|
|
789
|
-
/*
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
stylesheet order can't decide the winner. */
|
|
798
|
+
/* An icon button in the sidebar header (filter funnel, overflow …). Sized like the thread actions:
|
|
799
|
+
a TldrawUiButton (type="icon") left at its native 40px box, with .tlui-button's 32px ::after
|
|
800
|
+
square carrying the hover wash. Only the bits that differ are set here, at .tlui-button's
|
|
801
|
+
specificity + 1 so stylesheet order can't decide the winner. */
|
|
793
802
|
.tlui-button.tlui-cmt-header-btn {
|
|
794
|
-
width:
|
|
795
|
-
min-width: 26px;
|
|
796
|
-
height: 26px;
|
|
803
|
+
width: 40px;
|
|
797
804
|
padding: 0;
|
|
798
805
|
border-radius: 6px;
|
|
799
806
|
color: var(--tl-color-text-2);
|
|
800
807
|
}
|
|
801
|
-
/*
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
.tlui-button.tlui-cmt-header-btn::after {
|
|
806
|
-
inset: -6px -1px;
|
|
807
|
-
background: none;
|
|
808
|
-
}
|
|
809
|
-
.tlui-button.tlui-cmt-header-btn:first-child::after {
|
|
810
|
-
left: -6px;
|
|
811
|
-
}
|
|
812
|
-
.tlui-button.tlui-cmt-header-btn:last-child::after {
|
|
813
|
-
right: -6px;
|
|
808
|
+
/* Overlap each button 4px into the previous so the 32px visual squares sit 4px apart, not 8px —
|
|
809
|
+
the same tiling the thread actions use. */
|
|
810
|
+
.tlui-cmt-header-btn + .tlui-cmt-header-btn {
|
|
811
|
+
margin-left: -4px;
|
|
814
812
|
}
|
|
815
813
|
.tlui-button.tlui-cmt-header-btn:hover,
|
|
816
814
|
.tlui-button.tlui-cmt-header-btn[data-state='open'] {
|
|
817
|
-
background: var(--tl-color-muted-2);
|
|
818
815
|
color: var(--tl-color-text-1);
|
|
819
816
|
}
|
|
820
817
|
.tlui-cmt-list__header-actions {
|
|
821
818
|
display: flex;
|
|
822
819
|
align-items: center;
|
|
823
|
-
gap: 2px;
|
|
824
820
|
}
|
|
825
821
|
|
|
826
822
|
/* The commenting dropdowns' surface — a little air between the items and the menu edge. */
|
|
@@ -876,6 +872,7 @@ button.tlui-cmt-reaction--active:hover {
|
|
|
876
872
|
font-size: 12px;
|
|
877
873
|
}
|
|
878
874
|
.tlui-cmt-list__item {
|
|
875
|
+
position: relative;
|
|
879
876
|
display: flex;
|
|
880
877
|
gap: 10px;
|
|
881
878
|
width: 100%;
|
|
@@ -890,18 +887,27 @@ button.tlui-cmt-reaction--active:hover {
|
|
|
890
887
|
color: inherit;
|
|
891
888
|
text-decoration: none;
|
|
892
889
|
}
|
|
893
|
-
|
|
890
|
+
/* The highlight is an inset, rounded wash like tldraw's buttons and menu items — not an
|
|
891
|
+
edge-to-edge band — so a comment row lights up the same way every other interactive row does. */
|
|
892
|
+
.tlui-cmt-list__item::after {
|
|
893
|
+
content: '';
|
|
894
|
+
position: absolute;
|
|
895
|
+
inset: 4px;
|
|
896
|
+
border-radius: var(--tl-radius-2);
|
|
897
|
+
pointer-events: none;
|
|
898
|
+
}
|
|
899
|
+
.tlui-cmt-list__item:hover::after {
|
|
894
900
|
background: var(--tl-color-muted-2);
|
|
895
901
|
}
|
|
896
902
|
/* Selected sits a step above hover so the open thread's row stays distinguishable while
|
|
897
903
|
pointing at other rows — but only a step: the muted-1 token (10%) reads too heavy here.
|
|
898
904
|
These rows sit on the opaque sidebar panel, so a translucent tint composes safely. */
|
|
899
|
-
.tlui-cmt-list__item--selected,
|
|
900
|
-
.tlui-cmt-list__item--selected:hover {
|
|
905
|
+
.tlui-cmt-list__item--selected::after,
|
|
906
|
+
.tlui-cmt-list__item--selected:hover::after {
|
|
901
907
|
background: hsl(0, 0%, 0%, 5%);
|
|
902
908
|
}
|
|
903
|
-
.tl-theme__dark .tlui-cmt-list__item--selected,
|
|
904
|
-
.tl-theme__dark .tlui-cmt-list__item--selected:hover {
|
|
909
|
+
.tl-theme__dark .tlui-cmt-list__item--selected::after,
|
|
910
|
+
.tl-theme__dark .tlui-cmt-list__item--selected:hover::after {
|
|
905
911
|
background: hsl(0, 0%, 100%, 5%);
|
|
906
912
|
}
|
|
907
913
|
/* resolved rows mute their content but keep the "Resolved" marker legible */
|
|
@@ -11,7 +11,7 @@ const TWO_HOURS = 2 * 60 * 60
|
|
|
11
11
|
describe('formatRelativeTime', () => {
|
|
12
12
|
it('formats in English by default', () => {
|
|
13
13
|
expect(formatRelativeTime(agoIso(TWO_HOURS))).toBe(
|
|
14
|
-
new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: '
|
|
14
|
+
new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
|
|
15
15
|
)
|
|
16
16
|
})
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ describe('formatRelativeTime', () => {
|
|
|
20
20
|
it('formats in the given locale', () => {
|
|
21
21
|
const french = formatRelativeTime(agoIso(TWO_HOURS), 'fr')
|
|
22
22
|
expect(french).toBe(
|
|
23
|
-
new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: '
|
|
23
|
+
new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
|
|
24
24
|
)
|
|
25
25
|
expect(french).not.toBe(formatRelativeTime(agoIso(TWO_HOURS), 'en'))
|
|
26
26
|
})
|
|
@@ -34,7 +34,7 @@ describe('formatRelativeTime', () => {
|
|
|
34
34
|
|
|
35
35
|
it('reads under a minute as "now" in the given locale', () => {
|
|
36
36
|
expect(formatRelativeTime(agoIso(5), 'fr')).toBe(
|
|
37
|
-
new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: '
|
|
37
|
+
new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(0, 'second')
|
|
38
38
|
)
|
|
39
39
|
})
|
|
40
40
|
|
package/src/ui/format-time.ts
CHANGED
|
@@ -9,7 +9,7 @@ const DIVISIONS = [
|
|
|
9
9
|
] as const
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Format an ISO datetime as
|
|
12
|
+
* Format an ISO datetime as compact relative time ("2h ago", "yesterday", "last wk.").
|
|
13
13
|
* Locale-aware via Intl.RelativeTimeFormat.
|
|
14
14
|
* @public
|
|
15
15
|
*/
|
|
@@ -17,7 +17,7 @@ export function formatRelativeTime(iso: string, locale = 'en'): string {
|
|
|
17
17
|
const then = new Date(iso).getTime()
|
|
18
18
|
if (Number.isNaN(then)) return ''
|
|
19
19
|
|
|
20
|
-
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: '
|
|
20
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' })
|
|
21
21
|
let duration = (then - Date.now()) / 1000
|
|
22
22
|
// Under a minute is just "now" — with second granularity the label visibly ticks while
|
|
23
23
|
// typing a reply (every keystroke re-renders the card).
|
package/src/ui/icons.tsx
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
*
|
|
3
3
|
* Anything with an equivalent in tldraw's icon assets goes through `TldrawUiIcon` instead (the ⋯
|
|
4
4
|
* menu, the dismiss cross, the resolve button's check). What's left lives here because the asset
|
|
5
|
-
* set has no counterpart — a smiley, a send arrow,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* set has no counterpart — a smiley, a send arrow, a filter — or because the asset reads wrong at
|
|
6
|
+
* the size it's needed, which is the case for both resolved checks below. The eye pair mirrors
|
|
7
|
+
* tldraw's presence glyphs: the open state is the `follow` icon — the almond eye whose pupil
|
|
8
|
+
* tracks presence — and the closed state the `closed` icon, its shut-lid companion. Both are in
|
|
9
|
+
* the asset set, but they're inlined because the package renders without an `AssetUrlsProvider`,
|
|
10
|
+
* so `TldrawUiIcon` can't reach them. Inline also means they render without the consumer serving
|
|
11
|
+
* files.
|
|
8
12
|
*
|
|
9
13
|
* All internal — none are exported from the package. */
|
|
10
14
|
|
|
@@ -73,43 +77,61 @@ export function SendIcon() {
|
|
|
73
77
|
)
|
|
74
78
|
}
|
|
75
79
|
|
|
76
|
-
/** Comment pins are showing.
|
|
80
|
+
/** Comment pins are showing. tldraw's `follow` glyph (icons/icon/follow.svg) inlined at its native
|
|
81
|
+
* weight — the almond eye with a centred pupil. Pairs with `EyeClosedIcon`; the two must stay the
|
|
82
|
+
* same family. */
|
|
77
83
|
export function EyeOpenIcon() {
|
|
78
84
|
return (
|
|
79
|
-
<svg width="15" height="15" viewBox="0 0
|
|
85
|
+
<svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
|
|
86
|
+
<path fill="currentColor" d="M19 15a4 4 0 1 1-8 0 4 4 0 0 1 8 0" />
|
|
80
87
|
<path
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
clipRule="evenodd"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
strokeWidth="2"
|
|
90
|
+
d="M26 15c0 1.77-1.077 3.496-3.07 4.825C20.946 21.149 18.145 22 15 22s-5.945-.851-7.93-2.175C5.076 18.496 4 16.77 4 15c0-1.77 1.077-3.496 3.07-4.825C9.054 8.851 11.855 8 15 8s5.945.851 7.93 2.175C24.924 11.504 26 13.23 26 15Z"
|
|
85
91
|
/>
|
|
86
92
|
</svg>
|
|
87
93
|
)
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
/** Comment pins are hidden.
|
|
96
|
+
/** Comment pins are hidden. tldraw's `closed` eye glyph (icons/icon/closed.svg) — a shut lower lid
|
|
97
|
+
* with lashes, the design pair to `follow`. Inlined like the open eye: the package renders without
|
|
98
|
+
* an `AssetUrlsProvider`. Lash dots are `r=1.5` (vs the asset's `r=1`) because at the 15px header
|
|
99
|
+
* size the asset's finer lashes fall below a pixel; the lid keeps `follow`'s native stroke. */
|
|
91
100
|
export function EyeClosedIcon() {
|
|
92
101
|
return (
|
|
93
|
-
<svg width="15" height="15" viewBox="0 0
|
|
102
|
+
<svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
|
|
94
103
|
<path
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
stroke="currentColor"
|
|
105
|
+
strokeWidth="2"
|
|
106
|
+
strokeLinecap="round"
|
|
107
|
+
d="M27 15C27 19.4183 21.6274 23 15 23C8.37258 23 3 19.4183 3 15"
|
|
99
108
|
/>
|
|
109
|
+
<circle cx="4" cy="20" r="1.5" fill="currentColor" />
|
|
110
|
+
<circle cx="9" cy="23" r="1.5" fill="currentColor" />
|
|
111
|
+
<circle cx="15" cy="24" r="1.5" fill="currentColor" />
|
|
112
|
+
<circle cx="21" cy="23" r="1.5" fill="currentColor" />
|
|
113
|
+
<circle cx="26" cy="20" r="1.5" fill="currentColor" />
|
|
100
114
|
</svg>
|
|
101
115
|
)
|
|
102
116
|
}
|
|
103
117
|
|
|
104
|
-
/** The sidebar's
|
|
105
|
-
|
|
118
|
+
/** The sidebar's comment-menu trigger — tldraw's `dots-vertical` glyph (icons/icon/dots-vertical.svg)
|
|
119
|
+
* inlined, a vertical kebab. Inlined like the eye pair because the package renders without an
|
|
120
|
+
* `AssetUrlsProvider`. */
|
|
121
|
+
export function MoreMenuIcon() {
|
|
106
122
|
return (
|
|
107
|
-
<svg width="15" height="15" viewBox="0 0
|
|
123
|
+
<svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
|
|
108
124
|
<path
|
|
109
|
-
d="M5.5 3C4.67157 3 4 3.67157 4 4.5C4 5.32843 4.67157 6 5.5 6C6.32843 6 7 5.32843 7 4.5C7 3.67157 6.32843 3 5.5 3ZM3 5C3.01671 5 3.03323 4.99918 3.04952 4.99758C3.28022 6.1399 4.28967 7 5.5 7C6.71033 7 7.71978 6.1399 7.95048 4.99758C7.96677 4.99918 7.98329 5 8 5H13.5C13.7761 5 14 4.77614 14 4.5C14 4.22386 13.7761 4 13.5 4H8C7.98329 4 7.96677 4.00082 7.95048 4.00242C7.71978 2.86009 6.71033 2 5.5 2C4.28967 2 3.28022 2.86009 3.04952 4.00242C3.03323 4.00082 3.01671 4 3 4H1.5C1.22386 4 1 4.22386 1 4.5C1 4.77614 1.22386 5 1.5 5H3ZM11.9505 10.9976C11.7198 12.1399 10.7103 13 9.5 13C8.28967 13 7.28022 12.1399 7.04952 10.9976C7.03323 10.9992 7.01671 11 7 11H1.5C1.22386 11 1 10.7761 1 10.5C1 10.2239 1.22386 10 1.5 10H7C7.01671 10 7.03323 10.0008 7.04952 10.0024C7.28022 8.8601 8.28967 8 9.5 8C10.7103 8 11.7198 8.8601 11.9505 10.0024C11.9668 10.0008 11.9833 10 12 10H13.5C13.7761 10 14 10.2239 14 10.5C14 10.7761 13.7761 11 13.5 11H12C11.9833 11 11.9668 10.9992 11.9505 10.9976ZM8 10.5C8 9.67157 8.67157 9 9.5 9C10.3284 9 11 9.67157 11 10.5C11 11.3284 10.3284 12 9.5 12C8.67157 12 8 11.3284 8 10.5Z"
|
|
110
125
|
fill="currentColor"
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
d="M15 9.48975C14.3167 9.48975 13.7292 9.24747 13.2375 8.76293C12.7458 8.27003 12.5 7.67688 12.5 6.98348C12.5 6.29843 12.7458 5.71364 13.2375 5.22909C13.7292 4.73619 14.3167 4.48975 15 4.48975C15.6833 4.48975 16.2708 4.73619 16.7625 5.22909C17.2542 5.71364 17.5 6.29843 17.5 6.98348C17.5 7.44296 17.3833 7.86485 17.15 8.24914C16.925 8.62508 16.625 8.92583 16.25 9.1514C15.875 9.37696 15.4583 9.48975 15 9.48975Z"
|
|
127
|
+
/>
|
|
128
|
+
<path
|
|
129
|
+
fill="currentColor"
|
|
130
|
+
d="M15 17.5C14.3167 17.5 13.7292 17.2577 13.2375 16.7732C12.7458 16.2803 12.5 15.6871 12.5 14.9937C12.5 14.3087 12.7458 13.7239 13.2375 13.2393C13.7292 12.7464 14.3167 12.5 15 12.5C15.6833 12.5 16.2708 12.7464 16.7625 13.2393C17.2542 13.7239 17.5 14.3087 17.5 14.9937C17.5 15.4532 17.3833 15.8751 17.15 16.2594C16.925 16.6353 16.625 16.9361 16.25 17.1617C15.875 17.3872 15.4583 17.5 15 17.5Z"
|
|
131
|
+
/>
|
|
132
|
+
<path
|
|
133
|
+
fill="currentColor"
|
|
134
|
+
d="M15 25.5103C14.3167 25.5103 13.7292 25.268 13.2375 24.7834C12.7458 24.2905 12.5 23.6974 12.5 23.004C12.5 22.319 12.7458 21.7342 13.2375 21.2496C13.7292 20.7567 14.3167 20.5103 15 20.5103C15.6833 20.5103 16.2708 20.7567 16.7625 21.2496C17.2542 21.7342 17.5 22.319 17.5 23.004C17.5 23.4635 17.3833 23.8854 17.15 24.2697C16.925 24.6456 16.625 24.9463 16.25 25.1719C15.875 25.3975 15.4583 25.5103 15 25.5103Z"
|
|
113
135
|
/>
|
|
114
136
|
</svg>
|
|
115
137
|
)
|