mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: annotations + talk view improvements
This commit is contained in:
23 files changed
+12065
-180
No files matched your search
@@ -1245,8 +1245,13 @@ delete anybody's. The two are not interchangeable and neither implies the other.
|
||||
moderation queue yet, which is what the `meta` column is room for.
|
||||
- **Replies are one level deep, enforced in the model**: a `parentId` naming a comment that is itself a
|
||||
reply is rewritten to that reply's own parent, so answering the third message in a thread puts the
|
||||
answer at the bottom of the thread. Deleting a comment takes its replies with it, by the foreign
|
||||
key's own cascade — half a conversation is not worth keeping.
|
||||
answer at the bottom of the thread.
|
||||
- **Deleting a comment keeps its replies** — they are other people's words. A comment that has any is
|
||||
kept as a placeholder row (`deletedAt` set; content, author and annotations cleared) that draws as
|
||||
"Comment was deleted." with the thread going on under it; one with none is deleted outright, and a
|
||||
placeholder goes once the last reply under it does (`discard` in the model). Taking the whole
|
||||
thread is `withReplies` on the delete route and is a moderator's call: `manage:comments` on the
|
||||
page, never the author's. Placeholders are not counted on the Talk badge.
|
||||
- **Markdown is rendered in the browser, at display time**, by `frontend/src/renderers/comment.js` —
|
||||
a second, much smaller renderer than the page pipeline. `html: false` is the whole security
|
||||
boundary: markdown-it escapes every `<` it is given, so nothing stored is ever HTML and no
|
||||
@@ -1260,6 +1265,19 @@ delete anybody's. The two are not interchangeable and neither implies the other.
|
||||
provider owns a wiki mention handle. The comments endpoint resolves the handles of a whole page in
|
||||
one query and the renderer links only those, so a mention never points at whoever took the handle
|
||||
later.
|
||||
- **A comment that starts a thread can annotate the article**: passages picked from it (New
|
||||
Annotation, `PageAnnotator.vue`), each with a note, posted together as one comment whose own text
|
||||
may then be empty. Rows in `commentAnnotations`, signed-in authors only. A passage is stored as a
|
||||
DESCRIPTION — its text, ~32 characters either side, the heading it sat under — never as a pointer
|
||||
into the render, and is found again in the browser against the article as drawn
|
||||
(`helpers/annotations.js`): whitespace folded, found while the exact text is still there and can be
|
||||
told apart from its other occurrences, reported lost otherwise. Highlights use the CSS Custom
|
||||
Highlight API so nothing is ever inserted into the article — which also means a highlight cannot be
|
||||
clicked: View All Annotations (`PageAnnotationsLayer.vue`) hit-tests a click against each passage's
|
||||
client rects to open its note. Resolving, reopening and deleting one
|
||||
is the author's, `review:pages`' or `manage:comments`'; rewriting its note follows the comment's
|
||||
own edit rule. Deleting the last annotation of a comment with no text of its own deletes the
|
||||
comment too, in the same transaction and the same way its author would (`removeAnnotation`).
|
||||
- **The Talk tab is for the built-in provider alone.** `Article` / `Talk` above the content, as on
|
||||
Wikipedia, with a count badge that comes with the page (`commentsCount` on the page payload) rather
|
||||
than with the comments — it has to be there before the tab is opened. Every other provider draws
|
||||
|
||||
+411
-27
@@ -1,9 +1,14 @@
|
||||
import { audit } from '../helpers/audit.ts'
|
||||
import { maskSensitiveProps } from '../helpers/common.ts'
|
||||
import { mayOnPage } from './pages.ts'
|
||||
import { COMMENT_MAX_LENGTH, COMMENT_MIN_LENGTH } from '../models/comments.ts'
|
||||
import {
|
||||
ANNOTATION_NOTE_MAX_LENGTH,
|
||||
ANNOTATIONS_MAX,
|
||||
COMMENT_MAX_LENGTH,
|
||||
COMMENT_MIN_LENGTH
|
||||
} from '../models/comments.ts'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { CommentsProviderInput } from '../models/comments.ts'
|
||||
import type { AnnotationInput, CommentsProviderInput } from '../models/comments.ts'
|
||||
import type { RulePageRef } from '../helpers/pageRules.ts'
|
||||
|
||||
const siteIdParam = {
|
||||
@@ -35,6 +40,25 @@ const commentIdParam = {
|
||||
required: ['siteId', 'commentId']
|
||||
}
|
||||
|
||||
const annotationIdParam = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
annotationId: { type: 'string', format: 'uuid' }
|
||||
},
|
||||
required: ['siteId', 'annotationId']
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment's own text, which may be empty only where annotations carry what it has to say.
|
||||
*
|
||||
* Checked in the handler rather than by the schema's `minLength`, since whether it applies depends on
|
||||
* the rest of the request — or, for an edit, on what the comment was posted with.
|
||||
*/
|
||||
function isTooShort(content: string): boolean {
|
||||
return content.trim().length < COMMENT_MIN_LENGTH
|
||||
}
|
||||
|
||||
/**
|
||||
* A roughly-shaped email address, for the one a guest has to leave.
|
||||
*
|
||||
@@ -255,7 +279,10 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
const comments = await WIKI.models.comments.listForPage(page.id)
|
||||
const [mentions, total] = await Promise.all([
|
||||
WIKI.models.comments.resolveMentions(comments.map((c) => c.content)),
|
||||
// -> An annotation's note is drawn the way a comment is, mentions and all
|
||||
WIKI.models.comments.resolveMentions(
|
||||
comments.flatMap((c) => [c.content, ...c.annotations.map((a) => a.note)])
|
||||
),
|
||||
WIKI.models.comments.countForPage(page.id)
|
||||
])
|
||||
return { comments, mentions, total }
|
||||
@@ -268,14 +295,20 @@ async function routes(app: FastifyInstance) {
|
||||
// -> No route-level permissions: `write:comments` is a page rule. See the note above.
|
||||
app.post<{
|
||||
Params: { siteId: string; pageId: string }
|
||||
Body: { content: string; parentId?: string; authorName?: string; authorEmail?: string }
|
||||
Body: {
|
||||
content: string
|
||||
parentId?: string
|
||||
authorName?: string
|
||||
authorEmail?: string
|
||||
annotations?: AnnotationInput[]
|
||||
}
|
||||
}>(
|
||||
'/sites/:siteId/pages/:pageId/comments',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Post a comment on a page',
|
||||
description:
|
||||
'Needs `write:comments` on the page. A rule may grant it to the guests group, in which case a name and an email address are required of whoever is posting — the email is stored but never served, and is what the spam check is given.\n\nThe site’s posting cooldown applies to everybody who is not a moderator, counted per account and per address for a guest; going over it answers 429 with `Retry-After`. With an Akismet key configured, a comment Akismet calls spam is refused.',
|
||||
'Needs `write:comments` on the page. A rule may grant it to the guests group, in which case a name and an email address are required of whoever is posting — the email is stored but never served, and is what the spam check is given.\n\nA comment that starts a thread may carry `annotations`: passages of the article, each with a note about it. That needs a signed-in account — an annotation is resolved later by its author, and a guest has no session to be recognized by. With at least one annotation the comment’s own `content` may be empty.\n\nThe site’s posting cooldown applies to everybody who is not a moderator, counted per account and per address for a guest; going over it answers 429 with `Retry-After`. With an Akismet key configured, a comment Akismet calls spam is refused.',
|
||||
tags: ['Comments'],
|
||||
params: pageIdParam,
|
||||
body: {
|
||||
@@ -284,9 +317,8 @@ async function routes(app: FastifyInstance) {
|
||||
properties: {
|
||||
content: {
|
||||
type: 'string',
|
||||
minLength: COMMENT_MIN_LENGTH,
|
||||
maxLength: COMMENT_MAX_LENGTH,
|
||||
description: 'Markdown source. Raw HTML in it is escaped rather than rendered.'
|
||||
description: `Markdown source. Raw HTML in it is escaped rather than rendered. At least ${COMMENT_MIN_LENGTH} characters, unless the comment carries annotations.`
|
||||
},
|
||||
parentId: {
|
||||
type: 'string',
|
||||
@@ -305,6 +337,21 @@ async function routes(app: FastifyInstance) {
|
||||
type: 'string',
|
||||
maxLength: 255,
|
||||
description: 'Required of a guest. Stored, never served.'
|
||||
},
|
||||
annotations: {
|
||||
type: 'array',
|
||||
maxItems: ANNOTATIONS_MAX,
|
||||
description:
|
||||
'Passages of the article this comment is about, in the order they were picked. Not on a reply, and not from a guest.',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['note', 'anchor'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
note: { type: 'string', maxLength: ANNOTATION_NOTE_MAX_LENGTH },
|
||||
anchor: { $ref: 'CommentAnchor#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -329,6 +376,22 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const user = req.session?.authenticated ? req.session.user : null
|
||||
const annotations = req.body.annotations ?? []
|
||||
if (annotations.length > 0) {
|
||||
if (!user) {
|
||||
return reply.forbidden('Annotating a page needs an account.')
|
||||
}
|
||||
if (req.body.parentId) {
|
||||
return reply.badRequest(
|
||||
'A reply cannot annotate the page. Only a comment that starts a thread can.'
|
||||
)
|
||||
}
|
||||
if (annotations.some((annotation) => annotation.note.trim().length < 1)) {
|
||||
return reply.badRequest('Every annotation needs a note.')
|
||||
}
|
||||
} else if (isTooShort(req.body.content)) {
|
||||
return reply.badRequest('The comment is empty or too short.')
|
||||
}
|
||||
let authorName = user?.name ?? ''
|
||||
let authorEmail = user?.email ?? ''
|
||||
if (!user) {
|
||||
@@ -357,8 +420,11 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const origin = `${req.protocol}://${req.hostname}`
|
||||
// -> Everything the post says, notes included: a comment whose text is empty and whose
|
||||
// annotations are all links is exactly what the check is for
|
||||
const notes = annotations.map((annotation) => annotation.note.trim())
|
||||
const isSpam = await WIKI.models.comments.isSpam(req.params.siteId, {
|
||||
content: req.body.content,
|
||||
content: [req.body.content, ...notes].join('\n\n').trim(),
|
||||
authorName,
|
||||
authorEmail,
|
||||
authorIP: req.ip,
|
||||
@@ -385,7 +451,8 @@ async function routes(app: FastifyInstance) {
|
||||
authorId: user?.id ?? null,
|
||||
authorName,
|
||||
authorEmail: user ? '' : authorEmail,
|
||||
authorIP: req.ip
|
||||
authorIP: req.ip,
|
||||
annotations
|
||||
})
|
||||
} catch (err: any) {
|
||||
return reply.badRequest(err.message)
|
||||
@@ -397,7 +464,8 @@ async function routes(app: FastifyInstance) {
|
||||
path: page.path,
|
||||
locale: page.locale,
|
||||
isReply: Boolean(comment.parentId),
|
||||
isGuest: comment.isGuest
|
||||
isGuest: comment.isGuest,
|
||||
annotations: comment.annotations.length
|
||||
})
|
||||
// -> The email is a guest's alone: an account's
|
||||
// comment stores none, and `authorId` is what identifies it
|
||||
@@ -429,8 +497,14 @@ async function routes(app: FastifyInstance) {
|
||||
parentAuthorId: comment.parentId
|
||||
? await WIKI.models.comments.authorOf(comment.parentId)
|
||||
: null,
|
||||
excerpt: WIKI.models.comments.excerptOf(comment.content),
|
||||
mentionHandles: WIKI.models.comments.mentionedHandles(comment.content),
|
||||
// -> A comment posted as annotations alone has no text of its own, and is quoted by its
|
||||
// first note instead
|
||||
excerpt: WIKI.models.comments.excerptOf(
|
||||
isTooShort(comment.content) && notes.length > 0 ? notes[0]! : comment.content
|
||||
),
|
||||
mentionHandles: WIKI.models.comments.mentionedHandles(
|
||||
[comment.content, ...notes].join('\n')
|
||||
),
|
||||
// -> A guest's name is what they typed; an account's is looked up when the event is sent
|
||||
...(comment.authorId ? {} : { actorName: comment.authorName })
|
||||
}
|
||||
@@ -451,7 +525,7 @@ async function routes(app: FastifyInstance) {
|
||||
schema: {
|
||||
summary: 'Edit a comment',
|
||||
description:
|
||||
'Whoever holds `manage:comments` on the page may edit any comment on it; everybody else may edit their own, and only while they still hold `write:comments` there.\n\nA guest cannot edit at all: there is no session that identifies them as the author, so `their own` has nothing to mean.',
|
||||
'Whoever holds `manage:comments` on the page may edit any comment on it; everybody else may edit their own, and only while they still hold `write:comments` there.\n\nA guest cannot edit at all: there is no session that identifies them as the author, so `their own` has nothing to mean.\n\nOnly the comment’s own text changes here. Its annotations are edited one at a time, under `/annotations/:annotationId`, and while it has any the text may be empty.',
|
||||
tags: ['Comments'],
|
||||
params: commentIdParam,
|
||||
body: {
|
||||
@@ -460,7 +534,6 @@ async function routes(app: FastifyInstance) {
|
||||
properties: {
|
||||
content: {
|
||||
type: 'string',
|
||||
minLength: COMMENT_MIN_LENGTH,
|
||||
maxLength: COMMENT_MAX_LENGTH
|
||||
}
|
||||
}
|
||||
@@ -478,6 +551,12 @@ async function routes(app: FastifyInstance) {
|
||||
if (!comment) {
|
||||
return reply
|
||||
}
|
||||
if (
|
||||
isTooShort(req.body.content) &&
|
||||
(await WIKI.models.comments.countAnnotations(comment.id)) < 1
|
||||
) {
|
||||
return reply.badRequest('The comment is empty or too short.')
|
||||
}
|
||||
const updated = await WIKI.models.comments.update(comment.id, req.body.content)
|
||||
if (!updated) {
|
||||
return reply.notFound('This comment does not exist.')
|
||||
@@ -539,24 +618,44 @@ async function routes(app: FastifyInstance) {
|
||||
* DELETE A COMMENT
|
||||
*/
|
||||
// -> No route-level permissions: the two that matter here are page rules. See the note above.
|
||||
app.delete<{ Params: { siteId: string; commentId: string } }>(
|
||||
app.delete<{
|
||||
Params: { siteId: string; commentId: string }
|
||||
Querystring: { withReplies?: boolean }
|
||||
}>(
|
||||
'/sites/:siteId/comments/:commentId',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Delete a comment',
|
||||
description:
|
||||
'Same rule as editing: `manage:comments` on the page deletes any comment, `write:comments` deletes your own.\n\nThe replies underneath go with it. A reply exists to answer something, and left behind it is half of a conversation nobody can read.',
|
||||
'Same rule as editing: `manage:comments` on the page deletes any comment, `write:comments` deletes your own.\n\nThe replies underneath stay — they are other people’s words. A comment that has any is kept as a placeholder for them (`isDeleted` in the list, with its content, author and annotations cleared), and one that has none is deleted outright. A placeholder goes on its own once the last reply under it is deleted.\n\n`withReplies` deletes the whole thread instead, replies included. That is a moderator’s call and needs `manage:comments` on the page — an author may take back their own words, not the answers to them. It is also the only way to delete a placeholder directly; without it a placeholder answers 404.',
|
||||
tags: ['Comments'],
|
||||
params: commentIdParam,
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
withReplies: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Delete the replies under the comment too, rather than keeping them under a placeholder. Needs `manage:comments` on the page.'
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Comment deleted successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
deleted: {
|
||||
keptForReplies: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the comment was kept as a placeholder, because it has replies, rather than deleted outright.'
|
||||
},
|
||||
repliesDeleted: {
|
||||
type: 'integer',
|
||||
description: 'How many comments went, the replies underneath included.'
|
||||
description:
|
||||
'How many replies went with it. Only ever more than 0 with `withReplies`.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -564,20 +663,38 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const comment = await requireWritableComment(req, reply)
|
||||
const withReplies = req.query.withReplies === true
|
||||
const comment = await requireWritableComment(req, reply, { placeholder: withReplies })
|
||||
if (!comment) {
|
||||
return reply
|
||||
}
|
||||
const deleted = await WIKI.models.comments.remove(comment.id)
|
||||
if (
|
||||
withReplies &&
|
||||
!mayOnPage(req, 'manage:comments', {
|
||||
siteId: req.params.siteId,
|
||||
path: comment.path,
|
||||
locale: comment.locale,
|
||||
tags: comment.tags ?? []
|
||||
})
|
||||
) {
|
||||
return reply.forbidden('Only a moderator may delete the replies to a comment.')
|
||||
}
|
||||
const { removed, keptForReplies, repliesDeleted } = await WIKI.models.comments.remove(
|
||||
comment.id,
|
||||
{ withReplies }
|
||||
)
|
||||
if (!removed) {
|
||||
return reply.notFound('This comment does not exist.')
|
||||
}
|
||||
await audit(req, 'comment', 'deleteComment', {
|
||||
commentId: comment.id,
|
||||
pageId: comment.pageId,
|
||||
path: comment.path,
|
||||
deleted,
|
||||
keptForReplies,
|
||||
repliesDeleted,
|
||||
wasPlaceholder: Boolean(comment.deletedAt),
|
||||
isOwn: comment.authorId === req.session?.user?.id
|
||||
})
|
||||
// -> One event for the comment asked about, not one per reply the cascade took with it:
|
||||
// `deleted` says how many went
|
||||
await WIKI.models.hooks.emit('comment:delete', {
|
||||
id: comment.id,
|
||||
parentId: comment.parentId,
|
||||
@@ -587,14 +704,230 @@ async function routes(app: FastifyInstance) {
|
||||
siteId: req.params.siteId,
|
||||
authorId: comment.authorId,
|
||||
actorId: req.session?.user?.id ?? null,
|
||||
deleted,
|
||||
keptForReplies,
|
||||
repliesDeleted,
|
||||
metadata: {
|
||||
authorEmail: comment.authorEmail || null,
|
||||
authorIP: comment.authorIP || null,
|
||||
isGuest: comment.authorId === null
|
||||
}
|
||||
})
|
||||
return { ok: true, deleted }
|
||||
return { ok: true, keptForReplies, repliesDeleted }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* EDIT AN ANNOTATION'S NOTE
|
||||
*/
|
||||
// -> No route-level permissions: the two that matter here are page rules. See the note above.
|
||||
app.put<{ Params: { siteId: string; annotationId: string }; Body: { note: string } }>(
|
||||
'/sites/:siteId/annotations/:annotationId',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Edit the note of an annotation',
|
||||
description:
|
||||
'The same rule as editing the comment it belongs to: `manage:comments` on the page edits any note, and the comment’s author edits their own while they still hold `write:comments` there. The passage it is about cannot change — an annotation on a different passage is a different annotation.',
|
||||
tags: ['Comments'],
|
||||
params: annotationIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['note'],
|
||||
properties: {
|
||||
note: { type: 'string', maxLength: ANNOTATION_NOTE_MAX_LENGTH }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The annotation as it now stands',
|
||||
$ref: 'CommentAnnotation#'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const annotation = await requireAnnotation(req, reply, 'edit')
|
||||
if (!annotation) {
|
||||
return reply
|
||||
}
|
||||
if (req.body.note.trim().length < 1) {
|
||||
return reply.badRequest('An annotation needs a note.')
|
||||
}
|
||||
const updated = await WIKI.models.comments.updateAnnotationNote(annotation.id, req.body.note)
|
||||
if (!updated) {
|
||||
return reply.notFound('This annotation does not exist.')
|
||||
}
|
||||
await audit(req, 'comment', 'updateAnnotation', {
|
||||
annotationId: annotation.id,
|
||||
commentId: annotation.commentId,
|
||||
pageId: annotation.pageId,
|
||||
path: annotation.path,
|
||||
isOwn: annotation.authorId === req.session?.user?.id
|
||||
})
|
||||
// -> As for an edited comment: only the handles this edit added
|
||||
const before = new Set(WIKI.models.comments.mentionedHandles(annotation.note))
|
||||
const added = WIKI.models.comments
|
||||
.mentionedHandles(updated.note)
|
||||
.filter((handle) => !before.has(handle))
|
||||
if (added.length > 0) {
|
||||
await WIKI.models.notifications.emit('comment:edit', {
|
||||
siteId: req.params.siteId,
|
||||
actorId: req.session?.user?.id ?? null,
|
||||
data: {
|
||||
variant: 'edited',
|
||||
page: WIKI.models.notifications.pageSnapshot({
|
||||
id: annotation.pageId,
|
||||
title: annotation.title,
|
||||
path: annotation.path,
|
||||
locale: annotation.locale,
|
||||
tags: annotation.tags
|
||||
}),
|
||||
commentId: annotation.commentId,
|
||||
excerpt: WIKI.models.comments.excerptOf(updated.note),
|
||||
mentionHandles: added
|
||||
}
|
||||
})
|
||||
}
|
||||
return updated
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* RESOLVE OR REOPEN AN ANNOTATION
|
||||
*/
|
||||
// -> No route-level permissions: the three that matter here are page rules. See the note above.
|
||||
app.put<{ Params: { siteId: string; annotationId: string }; Body: { resolved: boolean } }>(
|
||||
'/sites/:siteId/annotations/:annotationId/resolved',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Resolve or reopen an annotation',
|
||||
description:
|
||||
'Marks the passage as dealt with, or open again. A resolved annotation stays in its comment, drawn as done.\n\nThe comment’s author may (while they hold `write:comments` on the page), and so may whoever holds `review:pages` or `manage:comments` there — reviewing a page is acting on what was said about it.',
|
||||
tags: ['Comments'],
|
||||
params: annotationIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['resolved'],
|
||||
properties: {
|
||||
resolved: { type: 'boolean' }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The annotation as it now stands',
|
||||
$ref: 'CommentAnnotation#'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const annotation = await requireAnnotation(req, reply, 'resolve')
|
||||
if (!annotation) {
|
||||
return reply
|
||||
}
|
||||
const updated = await WIKI.models.comments.setAnnotationResolved(
|
||||
annotation.id,
|
||||
req.body.resolved,
|
||||
req.session?.user?.id ?? null
|
||||
)
|
||||
if (!updated) {
|
||||
return reply.notFound('This annotation does not exist.')
|
||||
}
|
||||
// -> Only a change of state is an action: resolving one that was already resolved is not
|
||||
if (Boolean(annotation.resolvedAt) !== req.body.resolved) {
|
||||
await audit(req, 'comment', req.body.resolved ? 'resolveAnnotation' : 'reopenAnnotation', {
|
||||
annotationId: annotation.id,
|
||||
commentId: annotation.commentId,
|
||||
pageId: annotation.pageId,
|
||||
path: annotation.path,
|
||||
isOwn: annotation.authorId === req.session?.user?.id
|
||||
})
|
||||
}
|
||||
return updated
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE AN ANNOTATION
|
||||
*/
|
||||
// -> No route-level permissions: the three that matter here are page rules. See the note above.
|
||||
app.delete<{ Params: { siteId: string; annotationId: string } }>(
|
||||
'/sites/:siteId/annotations/:annotationId',
|
||||
{
|
||||
schema: {
|
||||
summary: 'Delete an annotation',
|
||||
description:
|
||||
'Takes one passage out of its comment. Whoever may resolve it may delete it: the comment’s author, and whoever holds `review:pages` or `manage:comments` on the page.\n\nA comment that was posted as annotations alone, with no text of its own, has nothing left to say once its last annotation goes — so it is deleted too, the way its author would delete it: its replies stay, under a placeholder. `commentDeleted` says when that happened.',
|
||||
tags: ['Comments'],
|
||||
params: annotationIdParam,
|
||||
response: {
|
||||
200: {
|
||||
description: 'Annotation deleted successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
commentDeleted: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the comment went too, because this was its last annotation and it had no text of its own.'
|
||||
},
|
||||
keptForReplies: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether that comment was kept as a placeholder for its replies rather than deleted outright.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const annotation = await requireAnnotation(req, reply, 'resolve')
|
||||
if (!annotation) {
|
||||
return reply
|
||||
}
|
||||
// -> Read before, since the comment may not be there after: a webhook about its deletion
|
||||
// carries what it was
|
||||
const comment = await WIKI.models.comments.getWithPage(
|
||||
annotation.commentId,
|
||||
req.params.siteId
|
||||
)
|
||||
const { removed, commentDeleted, keptForReplies } =
|
||||
await WIKI.models.comments.removeAnnotation(annotation.id)
|
||||
if (!removed) {
|
||||
return reply.notFound('This annotation does not exist.')
|
||||
}
|
||||
// -> One row for one act: the comment going is a consequence of this deletion, not a second
|
||||
// thing the reader did, so it is recorded here rather than as a `deleteComment` beside it
|
||||
await audit(req, 'comment', 'deleteAnnotation', {
|
||||
annotationId: annotation.id,
|
||||
commentId: annotation.commentId,
|
||||
pageId: annotation.pageId,
|
||||
path: annotation.path,
|
||||
isOwn: annotation.authorId === req.session?.user?.id,
|
||||
commentDeleted,
|
||||
...(commentDeleted && { keptForReplies })
|
||||
})
|
||||
// -> But a subscriber watching for comments that disappear is told, as for any other deletion
|
||||
if (commentDeleted && comment) {
|
||||
await WIKI.models.hooks.emit('comment:delete', {
|
||||
id: comment.id,
|
||||
parentId: comment.parentId,
|
||||
pageId: comment.pageId,
|
||||
path: comment.path,
|
||||
locale: comment.locale,
|
||||
siteId: req.params.siteId,
|
||||
authorId: comment.authorId,
|
||||
actorId: req.session?.user?.id ?? null,
|
||||
keptForReplies,
|
||||
repliesDeleted: 0,
|
||||
metadata: {
|
||||
authorEmail: comment.authorEmail || null,
|
||||
authorIP: comment.authorIP || null,
|
||||
isGuest: comment.authorId === null
|
||||
}
|
||||
})
|
||||
}
|
||||
return { ok: true, commentDeleted, keptForReplies }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -676,14 +1009,18 @@ async function requireBuiltInPage(
|
||||
*/
|
||||
async function requireWritableComment(
|
||||
req: FastifyRequest<{ Params: { siteId: string; commentId: string } }>,
|
||||
reply: FastifyReply
|
||||
reply: FastifyReply,
|
||||
{ placeholder = false }: { placeholder?: boolean } = {}
|
||||
) {
|
||||
if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) {
|
||||
reply.notFound('This site does not use the built-in comments provider.')
|
||||
return null
|
||||
}
|
||||
const comment = await WIKI.models.comments.getWithPage(req.params.commentId, req.params.siteId)
|
||||
if (!comment) {
|
||||
// -> A placeholder is what is left of a comment already deleted: nothing in it to edit, and only
|
||||
// the thread under it to delete -- which the caller says it is asking about. It has no author,
|
||||
// so only a moderator gets past the checks below either way.
|
||||
if (!comment || (comment.deletedAt && !placeholder)) {
|
||||
reply.notFound('This comment does not exist.')
|
||||
return null
|
||||
}
|
||||
@@ -704,6 +1041,53 @@ async function requireWritableComment(
|
||||
return comment
|
||||
}
|
||||
|
||||
/**
|
||||
* The annotation a request is about, once it is established that the caller may do what they asked.
|
||||
*
|
||||
* Two levels, because the two acts are not the same kind of thing. Editing a note is rewriting what
|
||||
* somebody said, which is the comment's own rule: `manage:comments`, or the author while they still
|
||||
* hold `write:comments`. Resolving and deleting are acting on the remark rather than rewording it,
|
||||
* and `review:pages` is enough for that too — a reviewer is whoever the page's remarks are FOR.
|
||||
*
|
||||
* @returns The annotation, or null once it has sent the reply itself
|
||||
*/
|
||||
async function requireAnnotation(
|
||||
req: FastifyRequest<{ Params: { siteId: string; annotationId: string } }>,
|
||||
reply: FastifyReply,
|
||||
action: 'edit' | 'resolve'
|
||||
) {
|
||||
if (!WIKI.models.comments.usesBuiltIn(req.params.siteId)) {
|
||||
reply.notFound('This site does not use the built-in comments provider.')
|
||||
return null
|
||||
}
|
||||
const annotation = await WIKI.models.comments.getAnnotationWithComment(
|
||||
req.params.annotationId,
|
||||
req.params.siteId
|
||||
)
|
||||
if (!annotation) {
|
||||
reply.notFound('This annotation does not exist.')
|
||||
return null
|
||||
}
|
||||
const page = {
|
||||
siteId: req.params.siteId,
|
||||
path: annotation.path,
|
||||
locale: annotation.locale,
|
||||
tags: annotation.tags ?? []
|
||||
}
|
||||
if (
|
||||
mayOnPage(req, 'manage:comments', page) ||
|
||||
(action === 'resolve' && mayOnPage(req, 'review:pages', page))
|
||||
) {
|
||||
return annotation
|
||||
}
|
||||
const userId = req.session?.authenticated ? req.session.user?.id : null
|
||||
if (!userId || annotation.authorId !== userId || !mayOnPage(req, 'write:comments', page)) {
|
||||
reply.forbidden('You are not allowed to modify this annotation.')
|
||||
return null
|
||||
}
|
||||
return annotation
|
||||
}
|
||||
|
||||
/**
|
||||
* Count this post against the site's cooldown, and say how long is left of it.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ANNOTATION_CONTEXT_MAX_LENGTH,
|
||||
ANNOTATION_QUOTE_MAX_LENGTH
|
||||
} from '../../models/comments.ts'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
@@ -77,6 +81,82 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
required: ['key']
|
||||
})
|
||||
|
||||
/**
|
||||
* COMMENT ANCHOR - Where an annotated passage is in the article
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'CommentAnchor',
|
||||
type: 'object',
|
||||
description:
|
||||
'The passage, described rather than pointed at, so that it can be found again after the page has been edited. Text is the article as drawn, with every run of whitespace folded into one space. A passage is found while its `exact` text is still in the page; `prefix`, `suffix`, `heading` and `offset` decide between the places it occurs.',
|
||||
required: ['exact', 'prefix', 'suffix', 'heading', 'offset'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
exact: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: ANNOTATION_QUOTE_MAX_LENGTH,
|
||||
description: 'The passage itself.'
|
||||
},
|
||||
prefix: {
|
||||
type: 'string',
|
||||
maxLength: ANNOTATION_CONTEXT_MAX_LENGTH,
|
||||
description: 'The text just before it.'
|
||||
},
|
||||
suffix: {
|
||||
type: 'string',
|
||||
maxLength: ANNOTATION_CONTEXT_MAX_LENGTH,
|
||||
description: 'The text just after it.'
|
||||
},
|
||||
heading: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
maxLength: 255,
|
||||
description: 'The id of the heading it sat under, or null above the first one.'
|
||||
},
|
||||
offset: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'How far into that heading’s section it started, in characters.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* COMMENT ANNOTATION - One passage of the article a comment is about
|
||||
*/
|
||||
app.addSchema({
|
||||
$id: 'CommentAnnotation',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
commentId: { type: 'string', format: 'uuid' },
|
||||
position: {
|
||||
type: 'integer',
|
||||
description: 'Where it comes in its comment, from 0.'
|
||||
},
|
||||
note: {
|
||||
type: 'string',
|
||||
description: 'What was said about the passage. Markdown, rendered the way a comment is.'
|
||||
},
|
||||
anchor: { $ref: 'CommentAnchor#' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
resolvedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
description: 'When it was marked done, or null while it is open.'
|
||||
},
|
||||
resolvedById: { type: 'string', format: 'uuid', nullable: true },
|
||||
resolvedByName: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Who marked it done, while that account exists.'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* COMMENT - One comment on one page, from the built-in provider
|
||||
*
|
||||
@@ -120,7 +200,18 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
nullable: true,
|
||||
description: 'The handle this author is mentioned by, or null if they have not set one.'
|
||||
},
|
||||
isGuest: { type: 'boolean' }
|
||||
isGuest: { type: 'boolean' },
|
||||
isDeleted: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether this is the placeholder a deleted comment leaves for its replies. It carries nothing of what was deleted: empty content, no author, no annotations.'
|
||||
},
|
||||
annotations: {
|
||||
type: 'array',
|
||||
description:
|
||||
'The passages of the article this comment is about, in the order they were picked. Only a comment that starts a thread has any.',
|
||||
items: { $ref: 'CommentAnnotation#' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "commentAnnotations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"commentId" uuid NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"note" text NOT NULL,
|
||||
"anchor" jsonb NOT NULL,
|
||||
"resolvedAt" timestamp,
|
||||
"resolvedById" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "comments" ADD COLUMN "deletedAt" timestamp;--> statement-breakpoint
|
||||
CREATE INDEX "commentAnnotations_commentId_idx" ON "commentAnnotations" ("commentId","position");--> statement-breakpoint
|
||||
ALTER TABLE "commentAnnotations" ADD CONSTRAINT "commentAnnotations_commentId_comments_id_fkey" FOREIGN KEY ("commentId") REFERENCES "comments"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "commentAnnotations" ADD CONSTRAINT "commentAnnotations_resolvedById_users_id_fkey" FOREIGN KEY ("resolvedById") REFERENCES "users"("id") ON DELETE SET NULL;
|
||||
File diff suppressed because it is too large.
Load diff
+54
-2
@@ -254,8 +254,14 @@ export const blocks = pgTable(
|
||||
*
|
||||
* Replies are one level deep and that is enforced in the model: a reply names the comment it answers
|
||||
* in `parentId`, and a reply to a reply is attached to that reply's own parent rather than nesting
|
||||
* further. The foreign key is self-referential and cascades, so deleting a comment takes the replies
|
||||
* under it — which is the whole of what a thread is here.
|
||||
* further.
|
||||
*
|
||||
* **Deleting a comment that has replies keeps its row**, emptied, with `deletedAt` set: the replies
|
||||
* are other people's words and stay where they were, under a placeholder saying the comment they
|
||||
* answered was deleted. Content, author and annotations are cleared from that row, so nothing of what
|
||||
* was deleted is left to serve. A comment with no replies is simply deleted, and so is a placeholder
|
||||
* once the last reply under it goes (`models/comments.ts`). The foreign key still cascades, for the
|
||||
* deletions that are not one comment at a time — a page, and everything said about it.
|
||||
*
|
||||
* `content` is markdown source and there is no stored render. It is turned into HTML in the reader's
|
||||
* browser (`frontend/src/renderers/comment.js`) with raw HTML disabled, the same way a page's
|
||||
@@ -302,6 +308,11 @@ export const comments = pgTable(
|
||||
* nothing should write a key into it without deciding what an absent one means.
|
||||
*/
|
||||
meta: jsonb().notNull().default({}),
|
||||
/**
|
||||
* When it was deleted, for a comment kept as a placeholder because it has replies. Null for every
|
||||
* comment that is still there. See the note on the table.
|
||||
*/
|
||||
deletedAt: timestamp(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow()
|
||||
},
|
||||
@@ -313,6 +324,47 @@ export const comments = pgTable(
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* A passage of the article a comment is about, and what was said about it.
|
||||
*
|
||||
* Rows of their own rather than a list in `comments.meta`, because each one has a life of its own
|
||||
* after it is posted: it is resolved and reopened, its note is edited and it is deleted, one at a
|
||||
* time and by people other than the comment's author. Only a comment that starts a thread carries
|
||||
* any — a reply answers the comment, not the page.
|
||||
*
|
||||
* **The passage is described, never pointed at.** `anchor` holds the quoted text, a little of what
|
||||
* surrounded it, and the heading it sat under — no element path, no offset into the stored render.
|
||||
* A page is edited after it is annotated, and a description is what can be found again in a page
|
||||
* that has moved on; a pointer is only right about the version it was taken from. The matching is
|
||||
* the browser's, against the article as drawn (`frontend/src/helpers/annotations.js`), which is the
|
||||
* same text the passage was selected from.
|
||||
*/
|
||||
export const commentAnnotations = pgTable(
|
||||
'commentAnnotations',
|
||||
{
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
commentId: uuid()
|
||||
.notNull()
|
||||
.references(() => comments.id, { onDelete: 'cascade' }),
|
||||
/** Where it comes in its comment, from 0 — the order the passages were picked in. */
|
||||
position: integer().notNull().default(0),
|
||||
/** What was said about the passage. Markdown, drawn the way a comment is. */
|
||||
note: text().notNull(),
|
||||
/**
|
||||
* The passage: `{ exact, prefix, suffix, heading, offset }`. See `CommentAnchor` in
|
||||
* `models/comments.ts`, which is the shape, and the note on the table, which is why.
|
||||
*/
|
||||
anchor: jsonb().notNull(),
|
||||
/** When it was marked done, or null while it is open. */
|
||||
resolvedAt: timestamp(),
|
||||
/** Who marked it done. Kept as an id only: a resolution is a state, not something said. */
|
||||
resolvedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('commentAnnotations_commentId_idx').on(table.commentId, table.position)]
|
||||
)
|
||||
|
||||
// GROUPS ------------------------------
|
||||
export const groups = pgTable(
|
||||
'groups',
|
||||
|
||||
+48
-1
@@ -145,6 +145,7 @@
|
||||
"admin.audit.actions.createPage": "Created a page",
|
||||
"admin.audit.actions.createSite": "Created a site",
|
||||
"admin.audit.actions.createUser": "Created a user",
|
||||
"admin.audit.actions.deleteAnnotation": "Deleted an annotation",
|
||||
"admin.audit.actions.deleteApprovalRule": "Deleted an approval rule",
|
||||
"admin.audit.actions.deleteAsset": "Deleted a file",
|
||||
"admin.audit.actions.deleteAuthStrategy": "Deleted an authentication strategy",
|
||||
@@ -194,11 +195,13 @@
|
||||
"admin.audit.actions.registerPasskey": "Registered a passkey",
|
||||
"admin.audit.actions.rejectPageEdit": "Declined an edit suggestion",
|
||||
"admin.audit.actions.renderPage": "Queued a page for rendering",
|
||||
"admin.audit.actions.reopenAnnotation": "Reopened an annotation",
|
||||
"admin.audit.actions.requestPasswordReset": "Requested a password reset",
|
||||
"admin.audit.actions.rerenderPages": "Re-rendered every page",
|
||||
"admin.audit.actions.resetPassword": "Reset a password from an emailed link",
|
||||
"admin.audit.actions.resetUserPassword": "Set a user's password",
|
||||
"admin.audit.actions.resizeAsset": "Resized an image",
|
||||
"admin.audit.actions.resolveAnnotation": "Resolved an annotation",
|
||||
"admin.audit.actions.restorePage": "Restored a deleted page",
|
||||
"admin.audit.actions.retryJob": "Retried a job",
|
||||
"admin.audit.actions.revokeApiKey": "Revoked an API key",
|
||||
@@ -216,6 +219,7 @@
|
||||
"admin.audit.actions.unsubscribeNotifications": "Unsubscribed from notification email",
|
||||
"admin.audit.actions.unwatchPage": "Stopped watching a page",
|
||||
"admin.audit.actions.updateAnalytics": "Changed the analytics configuration",
|
||||
"admin.audit.actions.updateAnnotation": "Edited an annotation",
|
||||
"admin.audit.actions.updateApiState": "Turned the API on or off",
|
||||
"admin.audit.actions.updateApprovalRule": "Updated an approval rule",
|
||||
"admin.audit.actions.updateAsset": "Renamed or moved a file",
|
||||
@@ -1944,21 +1948,62 @@
|
||||
"common.clipboard.uuidFailure": "Failed to copy UUID to clipboard.",
|
||||
"common.clipboard.uuidSuccess": "Copied UUID to clipboard successfully.",
|
||||
"common.comingSoon": "Coming soon",
|
||||
"common.comments.annotate": "Annotate",
|
||||
"common.comments.annotateHint": "Select a word or phrase in the article.",
|
||||
"common.comments.annotateIdleHint": "Click Annotate to pick another passage, or Review to finish.",
|
||||
"common.comments.annotateNoteHint": "Write a note about this passage, then click OK.",
|
||||
"common.comments.annotationCancelTitle": "Discard Annotations",
|
||||
"common.comments.annotationCancelWarn": "Discard the annotations you have not posted yet? They cannot be brought back.",
|
||||
"common.comments.annotationDeleteFailed": "Could not delete the annotation.",
|
||||
"common.comments.annotationDeleteLastWarn": "This is the comment's only annotation and the comment has no text of its own, so the comment is deleted with it. Any replies to it stay, under a note that the comment was deleted. None of it can be brought back.",
|
||||
"common.comments.annotationDeleteSuccess": "Annotation deleted.",
|
||||
"common.comments.annotationDeleteTitle": "Delete Annotation",
|
||||
"common.comments.annotationDeleteWarn": "Delete this annotation? It cannot be brought back.",
|
||||
"common.comments.annotationGeneralPlaceholder": "Add a comment about the page as a whole (optional)…",
|
||||
"common.comments.annotationGoBack": "Go Back",
|
||||
"common.comments.annotationGoTo": "Show in the article",
|
||||
"common.comments.annotationLocate": "Locate on page",
|
||||
"common.comments.annotationLost": "This passage has changed since it was annotated and can no longer be found in the article.",
|
||||
"common.comments.annotationLostTitle": "Passage no longer found in the article",
|
||||
"common.comments.annotationNotePlaceholder": "Write a note about this passage…",
|
||||
"common.comments.annotationRemove": "Remove annotation",
|
||||
"common.comments.annotationReopen": "Reopen",
|
||||
"common.comments.annotationResolve": "Resolve",
|
||||
"common.comments.annotationResolved": "Resolved",
|
||||
"common.comments.annotationResolvedBy": "Resolved by {name}",
|
||||
"common.comments.annotationReview": "Review",
|
||||
"common.comments.annotationReviewTitle": "Review Annotations",
|
||||
"common.comments.annotationTooLong": "That passage is too long to annotate. Select a phrase or a few sentences.",
|
||||
"common.comments.annotationUpdateFailed": "Could not update the annotation.",
|
||||
"common.comments.annotationViewComment": "View Comment",
|
||||
"common.comments.annotationsCount": "{count} annotation | {count} annotations",
|
||||
"common.comments.annotationsTitle": "Annotations",
|
||||
"common.comments.beFirst": "Be the first to comment.",
|
||||
"common.comments.charsLeft": "{count} left",
|
||||
"common.comments.closed": "Comments are turned off for this page.",
|
||||
"common.comments.commentsTitle": "Comments",
|
||||
"common.comments.contentMissingError": "Comment is empty or too short!",
|
||||
"common.comments.deleteCommentOnly": "Delete this comment only",
|
||||
"common.comments.deleteConfirmTitle": "Delete Comment",
|
||||
"common.comments.deleteFailed": "Could not delete the comment.",
|
||||
"common.comments.deletePermanentWarn": "This action cannot be undone!",
|
||||
"common.comments.deleteSuccess": "Comment deleted.",
|
||||
"common.comments.deleteWarn": "Delete this comment? Any replies to it go with it, and neither can be brought back.",
|
||||
"common.comments.deleteThread": "Delete Thread",
|
||||
"common.comments.deleteThreadSuccess": "Thread deleted.",
|
||||
"common.comments.deleteThreadTitle": "Delete Thread",
|
||||
"common.comments.deleteThreadWarn": "Delete every reply in this thread? None of them can be brought back.",
|
||||
"common.comments.deleteWarn": "Delete this comment? It cannot be brought back.",
|
||||
"common.comments.deleteWarnKeepReplies": "Delete this comment? It cannot be brought back. Its replies stay, under a note that the comment was deleted.",
|
||||
"common.comments.deleteWarnModerator": "This comment has replies. Delete it alone, keeping the replies under a note that the comment was deleted, or delete the whole thread? Neither can be undone.",
|
||||
"common.comments.deleteWithReplies": "Delete comment + replies",
|
||||
"common.comments.deleted": "Comment was deleted.",
|
||||
"common.comments.edited": "edited",
|
||||
"common.comments.fieldContent": "Comment Content",
|
||||
"common.comments.fieldEmail": "Your Email Address",
|
||||
"common.comments.fieldEmailHint": "Never shown to anybody. It is used to check the comment for spam.",
|
||||
"common.comments.fieldName": "Your Name",
|
||||
"common.comments.guest": "Guest",
|
||||
"common.comments.hideAllAnnotations": "Hide All",
|
||||
"common.comments.loadFailed": "Could not load the comments.",
|
||||
"common.comments.loading": "Loading comments…",
|
||||
"common.comments.markdownFormat": "Markdown Format",
|
||||
@@ -1974,6 +2019,7 @@
|
||||
"common.comments.postFailed": "Could not post the comment.",
|
||||
"common.comments.postReply": "Post Reply",
|
||||
"common.comments.postSuccess": "Comment posted.",
|
||||
"common.comments.postWithAnnotations": "Post Comment with Annotations",
|
||||
"common.comments.postingAs": "Posting as {name}",
|
||||
"common.comments.preview": "Preview",
|
||||
"common.comments.previewEmpty": "Nothing to preview yet.",
|
||||
@@ -1988,6 +2034,7 @@
|
||||
"common.comments.updateComment": "Save Changes",
|
||||
"common.comments.updateFailed": "Could not update the comment.",
|
||||
"common.comments.updateSuccess": "Comment updated.",
|
||||
"common.comments.viewAllAnnotations": "View All",
|
||||
"common.comments.viewDiscussion": "View Discussion",
|
||||
"common.comments.write": "Write",
|
||||
"common.createPage.api": "New API Documentation",
|
||||
|
||||
@@ -49,7 +49,15 @@ export const AUDIT_ACTIONS = {
|
||||
'deleteFolder'
|
||||
],
|
||||
asset: ['uploadAsset', 'updateAsset', 'resizeAsset', 'deleteAsset'],
|
||||
comment: ['createComment', 'updateComment', 'deleteComment'],
|
||||
comment: [
|
||||
'createComment',
|
||||
'updateComment',
|
||||
'deleteComment',
|
||||
'updateAnnotation',
|
||||
'resolveAnnotation',
|
||||
'reopenAnnotation',
|
||||
'deleteAnnotation'
|
||||
],
|
||||
auth: [
|
||||
'login',
|
||||
'logout',
|
||||
|
||||
+386
-30
@@ -1,8 +1,9 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { and, asc, count, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
|
||||
import {
|
||||
commentAnnotations as annotationsTable,
|
||||
comments as commentsTable,
|
||||
pages as pagesTable,
|
||||
users as usersTable
|
||||
@@ -87,6 +88,32 @@ export const COMMENT_MAX_LENGTH = 8000
|
||||
/** The shortest a comment may be, so that an empty box and a stray keystroke are both refused. */
|
||||
export const COMMENT_MIN_LENGTH = 2
|
||||
|
||||
/**
|
||||
* The most passages one comment may annotate.
|
||||
*
|
||||
* A review of a page is a handful of remarks about it; fifty is room for a thorough one, and a limit
|
||||
* at all is what keeps a single post from becoming a few thousand rows.
|
||||
*/
|
||||
export const ANNOTATIONS_MAX = 50
|
||||
|
||||
/** The longest an annotation's note may be. Shorter than a comment: it is a remark on one passage. */
|
||||
export const ANNOTATION_NOTE_MAX_LENGTH = 2000
|
||||
|
||||
/**
|
||||
* The longest passage that may be annotated, in characters of the article's text.
|
||||
*
|
||||
* A passage is quoted back in the Talk view and searched for in the article every time the
|
||||
* discussion is opened, so it is a phrase or a few sentences — a remark on a whole section belongs in
|
||||
* the comment itself.
|
||||
*/
|
||||
export const ANNOTATION_QUOTE_MAX_LENGTH = 1000
|
||||
|
||||
/**
|
||||
* The most context kept either side of a passage. The client takes 32 characters; the slack is so
|
||||
* that the two can be tuned apart without the server refusing what an older tab sends.
|
||||
*/
|
||||
export const ANNOTATION_CONTEXT_MAX_LENGTH = 64
|
||||
|
||||
/** How long a client waits between posts when nothing is configured, in seconds. */
|
||||
const DEFAULT_POST_COOLDOWN = 30
|
||||
|
||||
@@ -171,6 +198,46 @@ export interface CommentsPublicConfig {
|
||||
maxLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an annotated passage is in the article, described so that it can be found again after the
|
||||
* page has changed. Text throughout is the article's as `frontend/src/helpers/annotations.js` reads
|
||||
* it: the rendered page with every run of whitespace folded into one space.
|
||||
*/
|
||||
export interface CommentAnchor {
|
||||
/** The passage itself. What has to still be in the page for the annotation to be found. */
|
||||
exact: string
|
||||
/** The text just before it, which is what tells apart two places the same phrase occurs. */
|
||||
prefix: string
|
||||
/** The text just after it, likewise. */
|
||||
suffix: string
|
||||
/** The id of the heading it sat under, or null above the first heading. */
|
||||
heading: string | null
|
||||
/** How far into that heading's section it started, in characters — the last tie-breaker. */
|
||||
offset: number
|
||||
}
|
||||
|
||||
/** One annotation as the client posts it, alongside the comment it belongs to. */
|
||||
export interface AnnotationInput {
|
||||
note: string
|
||||
anchor: CommentAnchor
|
||||
}
|
||||
|
||||
/** One annotation as the API answers with it. */
|
||||
export interface AnnotationEntry {
|
||||
id: string
|
||||
commentId: string
|
||||
position: number
|
||||
note: string
|
||||
anchor: CommentAnchor
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
/** When it was marked done, or null while it is open. */
|
||||
resolvedAt: Date | null
|
||||
resolvedById: string | null
|
||||
/** Who marked it done, while that account exists. */
|
||||
resolvedByName: string | null
|
||||
}
|
||||
|
||||
/** One comment as the API answers with it. Neither the email nor the address is ever in here. */
|
||||
export interface CommentEntry {
|
||||
id: string
|
||||
@@ -187,6 +254,13 @@ export interface CommentEntry {
|
||||
authorHandle: string | null
|
||||
/** Whether the comment was written by somebody with no account. */
|
||||
isGuest: boolean
|
||||
/**
|
||||
* Whether this is the placeholder a deleted comment leaves behind for its replies. Such an entry
|
||||
* carries nothing of what was deleted: no content, no author, no annotations.
|
||||
*/
|
||||
isDeleted: boolean
|
||||
/** The passages of the article it is about, in the order they were picked. Empty for most. */
|
||||
annotations: AnnotationEntry[]
|
||||
}
|
||||
|
||||
/** A handle that resolved to somebody, as the renderer needs it to draw the mention as a link. */
|
||||
@@ -205,6 +279,8 @@ export interface CommentInput {
|
||||
authorName: string
|
||||
authorEmail: string
|
||||
authorIP: string
|
||||
/** The passages it is about. Only for a comment that starts a thread — see `create`. */
|
||||
annotations?: AnnotationInput[]
|
||||
}
|
||||
|
||||
/** A comment as `insertThreads` stores it: written by somebody, at a time given rather than now. */
|
||||
@@ -647,6 +723,7 @@ class Comments {
|
||||
createdAt: commentsTable.createdAt,
|
||||
updatedAt: commentsTable.updatedAt,
|
||||
authorId: commentsTable.authorId,
|
||||
deletedAt: commentsTable.deletedAt,
|
||||
storedName: commentsTable.authorName,
|
||||
userName: usersTable.name,
|
||||
userHandle: usersTable.handle,
|
||||
@@ -657,6 +734,7 @@ class Comments {
|
||||
.where(eq(commentsTable.pageId, pageId))
|
||||
.orderBy(asc(commentsTable.createdAt))
|
||||
.limit(limit)
|
||||
const annotations = await this.annotationsFor(rows.map((row) => row.id))
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
parentId: row.parentId,
|
||||
@@ -669,7 +747,9 @@ class Comments {
|
||||
authorName: row.userName ?? row.storedName,
|
||||
authorHasAvatar: row.userHasAvatar ?? false,
|
||||
authorHandle: row.userHandle ?? null,
|
||||
isGuest: row.authorId === null
|
||||
isGuest: row.authorId === null && !row.deletedAt,
|
||||
isDeleted: Boolean(row.deletedAt),
|
||||
annotations: annotations.get(row.id) ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -698,12 +778,15 @@ class Comments {
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/** How many comments a page has. What the Talk tab's badge counts. */
|
||||
/**
|
||||
* How many comments a page has. What the Talk tab's badge counts -- so not the placeholders deleted
|
||||
* comments leave behind, which are nothing anybody said.
|
||||
*/
|
||||
async countForPage(pageId: string): Promise<number> {
|
||||
const [row] = await WIKI.db
|
||||
.select({ total: count() })
|
||||
.from(commentsTable)
|
||||
.where(eq(commentsTable.pageId, pageId))
|
||||
.where(and(eq(commentsTable.pageId, pageId), isNull(commentsTable.deletedAt)))
|
||||
return Number(row?.total ?? 0)
|
||||
}
|
||||
|
||||
@@ -722,6 +805,7 @@ class Comments {
|
||||
authorId: commentsTable.authorId,
|
||||
authorEmail: commentsTable.authorEmail,
|
||||
authorIP: commentsTable.authorIP,
|
||||
deletedAt: commentsTable.deletedAt,
|
||||
pageId: commentsTable.pageId,
|
||||
path: pagesTable.path,
|
||||
locale: pagesTable.locale,
|
||||
@@ -742,6 +826,10 @@ class Comments {
|
||||
* itself a reply is rewritten to that reply's own parent, so answering the third message in a thread
|
||||
* puts the answer at the bottom of the thread rather than starting a fourth level of indentation.
|
||||
* A `parentId` on another page is refused outright — that is not a thread, it is a mistake.
|
||||
*
|
||||
* Annotations are only for a comment that starts a thread, and a reply carrying any is refused
|
||||
* rather than having them dropped: a reply answers the comment above it, and the passages it was
|
||||
* posted about would be shown nowhere.
|
||||
*/
|
||||
async create(input: CommentInput): Promise<CommentEntry> {
|
||||
let parentId: string | null = null
|
||||
@@ -755,19 +843,38 @@ class Comments {
|
||||
}
|
||||
parentId = parent.parentId ?? parent.id
|
||||
}
|
||||
const [row] = await WIKI.db
|
||||
.insert(commentsTable)
|
||||
.values({
|
||||
pageId: input.pageId,
|
||||
parentId,
|
||||
content: input.content,
|
||||
authorId: input.authorId,
|
||||
authorName: input.authorName,
|
||||
authorEmail: input.authorEmail,
|
||||
authorIP: input.authorIP
|
||||
})
|
||||
.returning()
|
||||
return this.describe(row!)
|
||||
const annotations = input.annotations ?? []
|
||||
if (parentId && annotations.length > 0) {
|
||||
throw new Error('A reply cannot annotate the page. Only a comment that starts a thread can.')
|
||||
}
|
||||
// -> One transaction, so that a comment never exists without the passages it was posted about
|
||||
// -- its text may well be empty, and the annotations are then the whole of what it says
|
||||
const row = await WIKI.db.transaction(async (tx) => {
|
||||
const [comment] = await tx
|
||||
.insert(commentsTable)
|
||||
.values({
|
||||
pageId: input.pageId,
|
||||
parentId,
|
||||
content: input.content,
|
||||
authorId: input.authorId,
|
||||
authorName: input.authorName,
|
||||
authorEmail: input.authorEmail,
|
||||
authorIP: input.authorIP
|
||||
})
|
||||
.returning()
|
||||
if (annotations.length > 0) {
|
||||
await tx.insert(annotationsTable).values(
|
||||
annotations.map((annotation, position) => ({
|
||||
commentId: comment!.id,
|
||||
position,
|
||||
note: annotation.note.trim(),
|
||||
anchor: annotation.anchor
|
||||
}))
|
||||
)
|
||||
}
|
||||
return comment!
|
||||
})
|
||||
return this.describe(row, (await this.annotationsFor([row.id])).get(row.id))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -804,24 +911,191 @@ class Comments {
|
||||
.set({ content, updatedAt: new Date() })
|
||||
.where(eq(commentsTable.id, commentId))
|
||||
.returning()
|
||||
return row ? this.describe(row) : null
|
||||
return row ? this.describe(row, (await this.annotationsFor([row.id])).get(row.id)) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment, and with it any replies underneath.
|
||||
* Delete a comment.
|
||||
*
|
||||
* The replies go by the foreign key's own cascade rather than by a second statement: a reply exists
|
||||
* to answer something, and left behind it would be half of a conversation nobody can read.
|
||||
* Its replies stay unless `withReplies` asks otherwise: one that has any is kept as a placeholder
|
||||
* for them (see `discard`), and one that has none is deleted outright. With `withReplies` the
|
||||
* thread goes whole, by the foreign key's cascade -- a moderator's choice, which the route checks.
|
||||
* That is also the one way a placeholder is deleted directly: what is left under it is the thread.
|
||||
*
|
||||
* @returns How many rows went, replies included
|
||||
* @returns Whether there was one to delete, whether it was kept as a placeholder, and how many
|
||||
* replies went with it
|
||||
*/
|
||||
async remove(commentId: string): Promise<number> {
|
||||
const replies = await WIKI.db
|
||||
async remove(
|
||||
commentId: string,
|
||||
{ withReplies = false }: { withReplies?: boolean } = {}
|
||||
): Promise<{ removed: boolean; keptForReplies: boolean; repliesDeleted: number }> {
|
||||
return WIKI.db.transaction(async (tx) => {
|
||||
if (withReplies) {
|
||||
const [replies] = await tx
|
||||
.select({ total: count() })
|
||||
.from(commentsTable)
|
||||
.where(eq(commentsTable.parentId, commentId))
|
||||
const total = Number(replies?.total ?? 0)
|
||||
if (total > 0) {
|
||||
const result = await tx.delete(commentsTable).where(eq(commentsTable.id, commentId))
|
||||
const removed = (result.rowCount ?? 0) > 0
|
||||
return { removed, keptForReplies: false, repliesDeleted: removed ? total : 0 }
|
||||
}
|
||||
}
|
||||
const outcome = await discard(tx, commentId)
|
||||
return { removed: outcome !== null, keptForReplies: outcome === 'kept', repliesDeleted: 0 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The annotations of these comments, each comment's in the order they were picked.
|
||||
*
|
||||
* One query for a whole talk page, for the same reason the authors are joined rather than fetched:
|
||||
* the passages a comment is about are part of what the comment IS. Who resolved each one is joined
|
||||
* live, so a rename shows through, and falls away with the account.
|
||||
*/
|
||||
async annotationsFor(commentIds: string[]): Promise<Map<string, AnnotationEntry[]>> {
|
||||
const byComment = new Map<string, AnnotationEntry[]>()
|
||||
if (commentIds.length < 1) {
|
||||
return byComment
|
||||
}
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
annotation: annotationsTable,
|
||||
resolvedByName: usersTable.name
|
||||
})
|
||||
.from(annotationsTable)
|
||||
.leftJoin(usersTable, eq(usersTable.id, annotationsTable.resolvedById))
|
||||
.where(inArray(annotationsTable.commentId, commentIds))
|
||||
.orderBy(asc(annotationsTable.commentId), asc(annotationsTable.position))
|
||||
for (const { annotation, resolvedByName } of rows) {
|
||||
const list = byComment.get(annotation.commentId) ?? []
|
||||
list.push(describeAnnotation(annotation, resolvedByName))
|
||||
byComment.set(annotation.commentId, list)
|
||||
}
|
||||
return byComment
|
||||
}
|
||||
|
||||
/** How many annotations a comment carries. What decides whether its own text may be empty. */
|
||||
async countAnnotations(commentId: string): Promise<number> {
|
||||
const [row] = await WIKI.db
|
||||
.select({ total: count() })
|
||||
.from(commentsTable)
|
||||
.where(eq(commentsTable.parentId, commentId))
|
||||
const result = await WIKI.db.delete(commentsTable).where(eq(commentsTable.id, commentId))
|
||||
return (result.rowCount ?? 0) > 0 ? 1 + Number(replies[0]?.total ?? 0) : 0
|
||||
.from(annotationsTable)
|
||||
.where(eq(annotationsTable.commentId, commentId))
|
||||
return Number(row?.total ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* One annotation with the comment and page it belongs to, which is what every permission check on
|
||||
* it needs: the comment's author, and the page a rule is matched against.
|
||||
*
|
||||
* @returns The annotation, or null when no such annotation exists on this site
|
||||
*/
|
||||
async getAnnotationWithComment(annotationId: string, siteId: string) {
|
||||
const [row] = await WIKI.db
|
||||
.select({
|
||||
id: annotationsTable.id,
|
||||
note: annotationsTable.note,
|
||||
resolvedAt: annotationsTable.resolvedAt,
|
||||
commentId: commentsTable.id,
|
||||
authorId: commentsTable.authorId,
|
||||
pageId: commentsTable.pageId,
|
||||
path: pagesTable.path,
|
||||
locale: pagesTable.locale,
|
||||
tags: pagesTable.tags,
|
||||
title: pagesTable.title
|
||||
})
|
||||
.from(annotationsTable)
|
||||
.innerJoin(commentsTable, eq(commentsTable.id, annotationsTable.commentId))
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, commentsTable.pageId))
|
||||
.where(and(eq(annotationsTable.id, annotationId), eq(pagesTable.siteId, siteId)))
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/** Replace the note of an annotation. Who may is decided by the route; this only writes. */
|
||||
async updateAnnotationNote(annotationId: string, note: string): Promise<AnnotationEntry | null> {
|
||||
await WIKI.db
|
||||
.update(annotationsTable)
|
||||
.set({ note: note.trim(), updatedAt: new Date() })
|
||||
.where(eq(annotationsTable.id, annotationId))
|
||||
return this.getAnnotation(annotationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an annotation done, or open again.
|
||||
*
|
||||
* `updatedAt` is left alone: it says when the note was last changed, and resolving is not a change
|
||||
* to what anybody wrote — `resolvedAt` is its own record of when.
|
||||
*
|
||||
* @param resolvedById Who resolved it, or null to reopen it
|
||||
*/
|
||||
async setAnnotationResolved(
|
||||
annotationId: string,
|
||||
resolved: boolean,
|
||||
resolvedById: string | null
|
||||
): Promise<AnnotationEntry | null> {
|
||||
await WIKI.db
|
||||
.update(annotationsTable)
|
||||
.set(
|
||||
resolved
|
||||
? { resolvedAt: new Date(), resolvedById }
|
||||
: { resolvedAt: null, resolvedById: null }
|
||||
)
|
||||
.where(eq(annotationsTable.id, annotationId))
|
||||
return this.getAnnotation(annotationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one annotation, and the comment with it when that was all the comment had to say.
|
||||
*
|
||||
* A comment posted as annotations alone has no text of its own (`create`), so taking its last
|
||||
* annotation leaves a card with nothing in it -- that comment is deleted too, the same way
|
||||
* `remove` deletes one: outright, or kept as a placeholder where it has replies. A comment with text
|
||||
* of its own stays, whatever is left of its annotations. One transaction, so the count of what is
|
||||
* left and the deletion it decides agree.
|
||||
*
|
||||
* @returns Whether there was one to delete, whether the comment went with it, and whether that
|
||||
* comment was kept as a placeholder for its replies
|
||||
*/
|
||||
async removeAnnotation(
|
||||
annotationId: string
|
||||
): Promise<{ removed: boolean; commentDeleted: boolean; keptForReplies: boolean }> {
|
||||
return WIKI.db.transaction(async (tx) => {
|
||||
const [removed] = await tx
|
||||
.delete(annotationsTable)
|
||||
.where(eq(annotationsTable.id, annotationId))
|
||||
.returning({ commentId: annotationsTable.commentId })
|
||||
if (!removed) {
|
||||
return { removed: false, commentDeleted: false, keptForReplies: false }
|
||||
}
|
||||
const [comment] = await tx
|
||||
.select({ content: commentsTable.content })
|
||||
.from(commentsTable)
|
||||
.where(eq(commentsTable.id, removed.commentId))
|
||||
const [left] = await tx
|
||||
.select({ total: count() })
|
||||
.from(annotationsTable)
|
||||
.where(eq(annotationsTable.commentId, removed.commentId))
|
||||
if (
|
||||
!comment ||
|
||||
Number(left?.total ?? 0) > 0 ||
|
||||
comment.content.trim().length >= COMMENT_MIN_LENGTH
|
||||
) {
|
||||
return { removed: true, commentDeleted: false, keptForReplies: false }
|
||||
}
|
||||
const outcome = await discard(tx, removed.commentId)
|
||||
return { removed: true, commentDeleted: true, keptForReplies: outcome === 'kept' }
|
||||
})
|
||||
}
|
||||
|
||||
/** One annotation as the API answers with it. */
|
||||
private async getAnnotation(annotationId: string): Promise<AnnotationEntry | null> {
|
||||
const [row] = await WIKI.db
|
||||
.select({ annotation: annotationsTable, resolvedByName: usersTable.name })
|
||||
.from(annotationsTable)
|
||||
.leftJoin(usersTable, eq(usersTable.id, annotationsTable.resolvedById))
|
||||
.where(eq(annotationsTable.id, annotationId))
|
||||
return row ? describeAnnotation(row.annotation, row.resolvedByName) : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -991,7 +1265,10 @@ class Comments {
|
||||
}
|
||||
|
||||
/** One stored row as the API answers with it, for a write that already knows its author. */
|
||||
private describe(row: typeof commentsTable.$inferSelect): CommentEntry {
|
||||
private describe(
|
||||
row: typeof commentsTable.$inferSelect,
|
||||
annotations: AnnotationEntry[] = []
|
||||
): CommentEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
parentId: row.parentId,
|
||||
@@ -1002,11 +1279,90 @@ class Comments {
|
||||
authorName: row.authorName,
|
||||
authorHasAvatar: false,
|
||||
authorHandle: null,
|
||||
isGuest: row.authorId === null
|
||||
isGuest: row.authorId === null && !row.deletedAt,
|
||||
isDeleted: Boolean(row.deletedAt),
|
||||
annotations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A transaction, as `discard` is handed one. */
|
||||
type Transaction = Parameters<Parameters<typeof WIKI.db.transaction>[0]>[0]
|
||||
|
||||
/**
|
||||
* Delete a comment, keeping its replies.
|
||||
*
|
||||
* A comment with replies is kept as a placeholder: the replies are other people's words and stay
|
||||
* where they were, so the row they hang from stays too -- emptied of everything that was deleted.
|
||||
* Its text, its author and its annotations go; `deletedAt` is what is left to say a comment was
|
||||
* there. One with no replies is deleted outright, and so is the placeholder above a reply once that
|
||||
* reply was the last one under it: there is nothing left for it to hold.
|
||||
*
|
||||
* @returns `kept` for a placeholder, `deleted` for a row that went, or null when there was none
|
||||
*/
|
||||
async function discard(tx: Transaction, commentId: string): Promise<'kept' | 'deleted' | null> {
|
||||
const [comment] = await tx
|
||||
.select({ parentId: commentsTable.parentId })
|
||||
.from(commentsTable)
|
||||
.where(and(eq(commentsTable.id, commentId), isNull(commentsTable.deletedAt)))
|
||||
if (!comment) {
|
||||
return null
|
||||
}
|
||||
const [replies] = await tx
|
||||
.select({ total: count() })
|
||||
.from(commentsTable)
|
||||
.where(eq(commentsTable.parentId, commentId))
|
||||
if (Number(replies?.total ?? 0) > 0) {
|
||||
await tx.delete(annotationsTable).where(eq(annotationsTable.commentId, commentId))
|
||||
await tx
|
||||
.update(commentsTable)
|
||||
.set({
|
||||
content: '',
|
||||
authorId: null,
|
||||
authorName: '',
|
||||
authorEmail: '',
|
||||
authorIP: '',
|
||||
meta: {},
|
||||
deletedAt: new Date()
|
||||
})
|
||||
.where(eq(commentsTable.id, commentId))
|
||||
return 'kept'
|
||||
}
|
||||
await tx.delete(commentsTable).where(eq(commentsTable.id, commentId))
|
||||
if (comment.parentId) {
|
||||
const [left] = await tx
|
||||
.select({ total: count() })
|
||||
.from(commentsTable)
|
||||
.where(eq(commentsTable.parentId, comment.parentId))
|
||||
if (Number(left?.total ?? 0) < 1) {
|
||||
await tx
|
||||
.delete(commentsTable)
|
||||
.where(and(eq(commentsTable.id, comment.parentId), isNotNull(commentsTable.deletedAt)))
|
||||
}
|
||||
}
|
||||
return 'deleted'
|
||||
}
|
||||
|
||||
/** One stored annotation as the API answers with it. */
|
||||
function describeAnnotation(
|
||||
row: typeof annotationsTable.$inferSelect,
|
||||
resolvedByName: string | null
|
||||
): AnnotationEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
commentId: row.commentId,
|
||||
position: row.position,
|
||||
note: row.note,
|
||||
anchor: row.anchor as CommentAnchor,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
resolvedAt: row.resolvedAt,
|
||||
resolvedById: row.resolvedById,
|
||||
// -> Only while it was actually resolved by somebody who still has an account
|
||||
resolvedByName: row.resolvedAt ? resolvedByName : null
|
||||
}
|
||||
}
|
||||
|
||||
/** Props in the order the module meant them to be shown in, applied once so every consumer agrees. */
|
||||
function sortProps(props: Record<string, ModuleProp>): Record<string, ModuleProp> {
|
||||
return Object.fromEntries(Object.entries(props).sort(([, a], [, b]) => a.order - b.order))
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
|
||||
removing an icon; `check-icons.mjs` fails the build if this drifts.
|
||||
|
||||
304 icons.
|
||||
308 icons.
|
||||
*/
|
||||
export const BUNDLED_ICONS = {
|
||||
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
|
||||
@@ -46,9 +46,11 @@ export const BUNDLED_ICONS = {
|
||||
"la:cog": {"body":"<path fill=\"currentColor\" d=\"m13.188 3l-.157.813l-.594 2.968a10 10 0 0 0-2.593 1.532l-2.906-1l-.782-.25l-.406.718l-2 3.438l-.406.719l.594.53l2.25 1.97C6.104 14.948 6 15.46 6 16s.105 1.05.188 1.563l-2.25 1.968l-.594.532l.406.718l2 3.438l.406.718l.782-.25l2.906-1a10 10 0 0 0 2.594 1.532l.593 2.968l.156.813h5.626l.156-.813l.593-2.968a10 10 0 0 0 2.594-1.532l2.907 1l.78.25l.407-.718l2-3.438l.406-.718l-.593-.532l-2.25-1.968C25.895 17.05 26 16.538 26 16c0-.54-.105-1.05-.188-1.563l2.25-1.968l.594-.531l-.406-.72l-2-3.437l-.406-.718l-.782.25l-2.906 1a10 10 0 0 0-2.593-1.532l-.594-2.968L18.812 3zm1.624 2h2.376l.5 2.594l.125.593l.562.188a8 8 0 0 1 3.031 1.75l.438.406l.562-.187l2.532-.875l1.187 2.031l-2 1.781l-.469.375l.157.594c.128.57.187 1.152.187 1.75s-.059 1.18-.188 1.75l-.125.594l.438.375l2 1.781l-1.188 2.031l-2.53-.875l-.563-.187l-.438.406a8 8 0 0 1-3.031 1.75l-.563.188l-.125.593l-.5 2.594h-2.375l-.5-2.594l-.124-.593l-.563-.188a8 8 0 0 1-3.031-1.75l-.438-.406l-.562.187l-2.531.875L5.875 20.5l2-1.781l.469-.375l-.156-.594A8 8 0 0 1 8 16c0-.598.059-1.18.188-1.75l.156-.594l-.469-.375l-2-1.781l1.188-2.031l2.53.875l.563.187l.438-.406a8 8 0 0 1 3.031-1.75l.563-.188l.124-.593zM16 11c-2.75 0-5 2.25-5 5s2.25 5 5 5s5-2.25 5-5s-2.25-5-5-5m0 2c1.668 0 3 1.332 3 3s-1.332 3-3 3s-3-1.332-3-3s1.332-3 3-3\"/>","width":32,"height":32},
|
||||
"la:comment": {"body":"<path fill=\"currentColor\" d=\"M3 6v20h9.586L16 29.414L19.414 26H29V6zm2 2h22v16h-8.414L16 26.586L13.414 24H5zm4 3v2h14v-2zm0 4v2h14v-2zm0 4v2h10v-2z\"/>","width":32,"height":32},
|
||||
"la:comment-medical": {"body":"<path fill=\"currentColor\" d=\"M3 6v20h9.586L16 29.414L19.414 26H29V6zm2 2h22v16h-8.414L16 26.586L13.414 24H5zm10 3v4h-4v2h4v4h2v-4h4v-2h-4v-4z\"/>","width":32,"height":32},
|
||||
"la:comment-slash": {"body":"<path fill=\"currentColor\" d=\"M3.707 2.293L2.293 3.707l26 26l1.414-1.414L27.414 26H29V6H7.414zM3 7.243V26h9.586L16 29.414L19.414 26h2.344l-2-2h-1.172L16 26.586L13.414 24H5V9.242zM9.414 8H27v16h-1.586z\"/>","width":32,"height":32},
|
||||
"la:comments": {"body":"<path fill=\"currentColor\" d=\"M2 5v16h4v5.094l1.625-1.313L12.344 21H22V5zm2 2h16v12h-8.344l-.281.219L8 21.906V19H4zm20 2v2h4v12h-4v2.906L20.344 23h-7.5l-2.5 2h9.312L26 30.094V25h4V9z\"/>","width":32,"height":32},
|
||||
"la:copy": {"body":"<path fill=\"currentColor\" d=\"M4 4v20h7v-2H6V6h12v1h2V4zm8 4v20h16V8zm2 2h12v16H14z\"/>","width":32,"height":32},
|
||||
"la:crop": {"body":"<path fill=\"currentColor\" d=\"M8 4v4H4v2h4v14h14v4h2v-4h4v-2H11.437L22 11.437V21h2V9.437l3.719-3.718L26.28 4.28L22.563 8H11v2h9.563L10 20.563V4z\"/>","width":32,"height":32},
|
||||
"la:crosshairs": {"body":"<path fill=\"currentColor\" d=\"M15 3v2.063C9.734 5.538 5.54 9.733 5.062 15H3v2h2.063c.476 5.266 4.671 9.46 9.937 9.938V29h2v-2.063c5.266-.476 9.46-4.671 9.938-9.937H29v-2h-2.063c-.475-5.266-4.67-9.46-9.937-9.938V3zm0 4.031V9h2V7.031A9 9 0 0 1 24.969 15H23v2h1.969A9 9 0 0 1 17 24.969V23h-2v1.969A9 9 0 0 1 7.031 17H9v-2H7.031A9 9 0 0 1 15 7.031\"/>","width":32,"height":32},
|
||||
"la:css3-alt": {"body":"<path fill=\"currentColor\" d=\"m6 4l2 21l8 3l8-3l2-21zm3.332 3h13.32l-.261 3l-5.696 3h5.428l-.512 6.008l.02-.008l-.276 3L16 24l-5.365-2l-.33-4h3.021l.156 2.033l2.518.871l2.521-.853l.346-4.051h-8.736l-.258-3l5.91-3H9.61z\"/>","width":32,"height":32},
|
||||
"la:dharmachakra": {"body":"<path fill=\"currentColor\" d=\"M16 2.125c-.55 0-1 .45-1 1a1 1 0 0 0 .594.906a11.93 11.93 0 0 0-7.875 3.313a.95.95 0 0 0 .093-.375c0-.551-.449-1-1-1s-1 .449-1 1s.45 1 1 1a.95.95 0 0 0 .375-.094a11.95 11.95 0 0 0-3.156 7.719A1 1 0 0 0 3.125 15c-.55 0-1 .45-1 1s.45 1 1 1a1 1 0 0 0 .906-.594a11.95 11.95 0 0 0 3.157 7.719a.95.95 0 0 0-.375-.094c-.551 0-1 .45-1 1c0 .551.449 1 1 1s1-.449 1-1a.95.95 0 0 0-.094-.375a11.93 11.93 0 0 0 7.875 3.313a1 1 0 0 0-.594.906c0 .55.45 1 1 1s1-.45 1-1a1 1 0 0 0-.594-.906a11.93 11.93 0 0 0 7.875-3.313a.95.95 0 0 0-.093.375c0 .551.449 1 1 1s1-.449 1-1s-.45-1-1-1a.95.95 0 0 0-.375.094a11.95 11.95 0 0 0 3.156-7.719a1 1 0 0 0 .906.594c.55 0 1-.45 1-1s-.45-1-1-1a1 1 0 0 0-.906.594a11.95 11.95 0 0 0-3.157-7.719a1 1 0 0 0 .375.094c.551 0 1-.45 1-1c0-.551-.449-1-1-1s-1 .449-1 1c0 .136.043.254.094.375a11.93 11.93 0 0 0-7.875-3.313A1 1 0 0 0 17 3.125c0-.55-.45-1-1-1m-1.031 3.938H15v6.093a3.8 3.8 0 0 0-1.031.438L9.625 8.28a9.93 9.93 0 0 1 5.344-2.219zm2.031 0a9.96 9.96 0 0 1 5.375 2.218l-4.344 4.313A3.8 3.8 0 0 0 17 12.156zM8.219 9.719L12.563 14c-.184.313-.313.64-.407 1H6.062A9.94 9.94 0 0 1 8.22 9.719zm15.562 0A9.94 9.94 0 0 1 25.938 15h-6.094a4 4 0 0 0-.407-1zM16 14c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m-9.969 3h6.094c.094.36.25.688.438 1l-4.344 4.25C7.035 20.781 6.227 18.992 6.03 17zm13.844 0h6.094c-.196 1.992-1.004 3.781-2.188 5.25L19.438 18c.187-.313.343-.64.437-1m-5.906 2.406c.32.188.664.34 1.031.438v6.093a9.9 9.9 0 0 1-5.375-2.25zm4.062 0l4.344 4.282A9.9 9.9 0 0 1 17 25.938v-6.094a4.2 4.2 0 0 0 1.031-.438\"/>","width":32,"height":32},
|
||||
"la:dice-d6": {"body":"<path fill=\"currentColor\" d=\"M16 2.938L4.656 7.061L4 7.313v15.282l.531.281l11 6l.469.25l.469-.25l11-6l.531-.281V7.312l-.656-.25zm0 2.124l8.375 3.032L16 11.906L7.625 8.094zM16 7c-1.105 0-2 .45-2 1s.895 1 2 1s2-.45 2-1s-.895-1-2-1M6 9.531l9 4.094v12.688l-9-4.907zm20 0v11.875l-9 4.907V13.624zM12.656 14c-.37 0-.656.355-.656.875c0 .668.355 1.36.875 1.656c.148.074.32.063.469.063c.222 0 .351-.07.5-.219c.074-.148.156-.36.156-.656c0-.668-.45-1.36-.969-1.656c-.148-.075-.226-.063-.375-.063m7.375 1a1 1 0 0 0-.25.094c-.61.261-1.125 1.125-1.125 1.906c0 .262-.02.512.157.688c.175.175.363.28.625.28c.175 0 .355-.007.53-.093c.61-.348 1.032-1.148 1.032-1.844c0-.61-.258-1.031-.781-1.031c-.043 0-.114-.012-.188 0m-9.906 1.281c-.371 0-.656.356-.656.875c0 .668.355 1.328.875 1.625a.9.9 0 0 0 .437.094c.223 0 .383-.07.531-.219c.075-.148.157-.39.157-.687c0-.668-.45-1.328-.969-1.625c-.148-.074-.227-.063-.375-.063m13.406 1.157c-.074.011-.164.019-.25.062c-.61.262-1.125 1.156-1.125 1.938c0 .261.012.511.188.687s.332.25.593.25c.176 0 .356.023.532-.063c.61-.347 1.031-1.148 1.031-1.843c0-.61-.258-1.032-.781-1.032c-.043 0-.114-.011-.188 0zm-15.875.968c-.37 0-.656.356-.656.875c0 .668.355 1.36.875 1.657c.148.074.32.062.469.062c.222 0 .351-.07.5-.219c.074-.148.156-.36.156-.656c0-.668-.45-1.36-.969-1.656c-.148-.074-.226-.063-.375-.063\"/>","width":32,"height":32},
|
||||
@@ -65,6 +67,7 @@ export const BUNDLED_ICONS = {
|
||||
"la:external-link-alt": {"body":"<path fill=\"currentColor\" d=\"M18 5v2h5.563L11.28 19.281l1.438 1.438L25 8.437V14h2V5zM5 9v18h18V14l-2 2v9H7V11h9l2-2z\"/>","width":32,"height":32},
|
||||
"la:external-link-square-alt": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h22V5zm2 2h18v18H7zm6 3v2h5.563L9.28 21.281l1.438 1.438L20 13.437V19h2v-9z\"/>","width":32,"height":32},
|
||||
"la:eye": {"body":"<path fill=\"currentColor\" d=\"M16 8C7.664 8 1.25 15.344 1.25 15.344L.656 16l.594.656s5.848 6.668 13.625 7.282c.371.046.742.062 1.125.062s.754-.016 1.125-.063c7.777-.613 13.625-7.28 13.625-7.28l.594-.657l-.594-.656S24.336 8 16 8m0 2c2.203 0 4.234.602 6 1.406A6.9 6.9 0 0 1 23 15a6.995 6.995 0 0 1-6.219 6.969c-.02.004-.043-.004-.062 0c-.239.011-.477.031-.719.031c-.266 0-.523-.016-.781-.031A6.995 6.995 0 0 1 9 15c0-1.305.352-2.52.969-3.563h-.031C11.717 10.617 13.773 10 16 10m0 2a3 3 0 1 0 .002 6.002A3 3 0 0 0 16 12m-8.75.938A9 9 0 0 0 7 15c0 1.754.5 3.395 1.375 4.781A23.2 23.2 0 0 1 3.531 16a24 24 0 0 1 3.719-3.063zm17.5 0A24 24 0 0 1 28.469 16a23.2 23.2 0 0 1-4.844 3.781A8.93 8.93 0 0 0 25 15c0-.715-.094-1.398-.25-2.063z\"/>","width":32,"height":32},
|
||||
"la:eye-slash": {"body":"<path fill=\"currentColor\" d=\"M3.719 2.281L2.28 3.72L8.5 9.906L19.594 21l1.906 1.938l6.781 6.78l1.438-1.437l-6.219-6.218c4.234-2.098 7.074-5.211 7.25-5.407l.594-.656l-.594-.656C30.48 15.043 24.086 8 16 8c-1.957 0-3.805.43-5.5 1.063zM16 10c2.152 0 4.188.605 6 1.438A6.9 6.9 0 0 1 23 15a6.97 6.97 0 0 1-1.844 4.719l-2.843-2.844c.417-.512.687-1.16.687-1.875a3 3 0 0 0-3-3c-.715 0-1.363.27-1.875.688l-2.031-2.032C13.336 10.273 14.637 10 16 10m-9.313.906c-3.207 1.973-5.289 4.27-5.437 4.438L.656 16l.594.656c.258.29 6.18 6.77 13.813 7.282c.308.03.62.062.937.062a9 9 0 0 0 .938-.063a15 15 0 0 0 2.406-.343l-1.782-1.782A7 7 0 0 1 16 22c-3.86 0-7-3.14-7-7c0-.531.07-1.05.188-1.563zm.563 2.031A9 9 0 0 0 7 15a8.9 8.9 0 0 0 1.344 4.719A25.8 25.8 0 0 1 3.406 16a26 26 0 0 1 3.844-3.063m17.5 0A26.3 26.3 0 0 1 28.594 16c-.899.867-2.676 2.438-4.969 3.75A9 9 0 0 0 25 15c0-.7-.09-1.39-.25-2.063\"/>","width":32,"height":32},
|
||||
"la:file-alt": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.594l-.281-.313l-6-6L19.406 3zm2 2h10v6h6v16H8zm12 1.438L22.563 9H20zM11 13v2h10v-2zm0 4v2h10v-2zm0 4v2h10v-2z\"/>","width":32,"height":32},
|
||||
"la:file-download": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM15 13v5h-3l4 4l4-4h-3v-5zm-3 10v2h8v-2z\"/>","width":32,"height":32},
|
||||
"la:file-export": {"body":"<path fill=\"currentColor\" d=\"M6 4v24h20v-8l-2 2v4H8V6h16v4l2 2V4zm16.406 7L21 12.406L23.563 15h-9.657v2h9.656L21 19.594L22.406 21l4.313-4.281l.687-.719l-.687-.719z\"/>","width":32,"height":32},
|
||||
@@ -146,6 +149,7 @@ export const BUNDLED_ICONS = {
|
||||
"la:star": {"body":"<path fill=\"currentColor\" d=\"m16 2.125l-.906 2.063l-3.25 7.28l-7.938.845l-2.25.25l1.688 1.5l5.906 5.343l-1.656 7.813l-.469 2.187l1.969-1.125l6.906-4l6.906 4l1.969 1.125l-.469-2.187l-1.656-7.813l5.906-5.343l1.688-1.5l-2.25-.25l-7.938-.844l-3.25-7.281zm0 4.906l2.563 5.782l.25.53l.562.063l6.281.656l-4.687 4.22l-.438.405l.125.563l1.313 6.156l-5.469-3.125l-.5-.312l-.5.312l-5.469 3.125l1.313-6.156l.125-.563l-.438-.406l-4.687-4.218l6.281-.657l.563-.062l.25-.531z\"/>","width":32,"height":32},
|
||||
"la:star-half-alt": {"body":"<path fill=\"currentColor\" d=\"m16 2.375l-.906 2.031l-3.25 7.313l-7.938.812l-2.25.25l1.688 1.5l5.906 5.344l-1.656 7.813l-.469 2.187h.031L9.094 28.5L16 24.531l6.906 3.969l1.969 1.125l-.469-2.188l-1.656-7.812l5.906-5.344l1.688-1.5l-2.25-.25l-7.938-.812l-3.25-7.313zm0 4.906l2.563 5.782l.25.5l.562.062l6.313.656l-4.72 4.25l-.437.375l.125.563l1.313 6.187L16.5 22.5l-.5-.281z\"/>","width":32,"height":32},
|
||||
"la:star-solid": {"body":"<path fill=\"currentColor\" d=\"m30.336 12.547l-10.172-1.074L16 2.133l-4.164 9.34l-10.172 1.074l7.598 6.848L7.14 29.398L16 24.29l8.86 5.11l-2.122-10.004z\"/>","width":32,"height":32},
|
||||
"la:sticky-note": {"body":"<path fill=\"currentColor\" d=\"M5 5v22h15.406l.313-.281l6-6l.281-.313V5zm2 2h18v12h-6v6H7zm14 14h2.563L21 23.563z\"/>","width":32,"height":32},
|
||||
"la:stop": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6zm2 2h16v16H8z\"/>","width":32,"height":32},
|
||||
"la:stop-circle": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.85 3 3 8.85 3 16s5.85 13 13 13s13-5.85 13-13S23.15 3 16 3m0 2c6.05 0 11 4.95 11 11s-4.95 11-11 11S5 22.05 5 16S9.95 5 16 5m-5 6v10h10V11zm2 2h6v6h-6z\"/>","width":32,"height":32},
|
||||
"la:stream": {"body":"<path fill=\"currentColor\" d=\"M5 5v6h19V5zm2 2h15v2H7zm2 6v6h19v-6zm2 2h15v2H11zm-6 6v6h19v-6zm2 2h15v2H7z\"/>","width":32,"height":32},
|
||||
|
||||
@@ -374,12 +374,12 @@
|
||||
<!-- CHANGE PASSWORD SCREEN -->
|
||||
<!-- ----------------------------------------------------- -->
|
||||
<template v-else-if="state.screen === `changePwd`">
|
||||
<p class="auth-subtitle" v-if="state.continuationToken">
|
||||
<p class="auth-subtitle" v-if="state.changePwdForced">
|
||||
{{ t('auth.changePwd.instructions') }}
|
||||
</p>
|
||||
<w-form ref="changePwdForm" @submit="changePwd">
|
||||
<w-input
|
||||
v-if="!state.continuationToken"
|
||||
v-if="!state.changePwdForced"
|
||||
ref="changePwdCurrentIpt"
|
||||
v-model="state.password"
|
||||
outlined
|
||||
@@ -585,6 +585,13 @@ const state = reactive({
|
||||
password: '',
|
||||
securityCode: '',
|
||||
continuationToken: '',
|
||||
/**
|
||||
* Whether the change-password screen was reached part way through a login, which proves the
|
||||
* current password already -- so the form asks only for the new one. Decided as the screen opens
|
||||
* rather than read off `continuationToken` as it goes: a successful change answers with no token,
|
||||
* and the form would put the current-password field back under the overlay on the way out.
|
||||
*/
|
||||
changePwdForced: false,
|
||||
newName: '',
|
||||
newEmail: '',
|
||||
newPassword: '',
|
||||
@@ -880,8 +887,9 @@ async function handleLoginResponse(resp) {
|
||||
switch (resp.nextAction) {
|
||||
case 'changePassword': {
|
||||
state.screen = 'changePwd'
|
||||
state.changePwdForced = Boolean(state.continuationToken)
|
||||
nextTick(() => {
|
||||
if (state.continuationToken) {
|
||||
if (state.changePwdForced) {
|
||||
changePwdNewPwdIpt.value.focus()
|
||||
} else {
|
||||
changePwdCurrentIpt.value.focus()
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
<template>
|
||||
<!--
|
||||
The note of the annotation that was clicked, beside its passage. Teleported, as the annotator's
|
||||
card is, so that `position: fixed` means the window.
|
||||
-->
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="openItem"
|
||||
ref="cardEl"
|
||||
class="page-annotation-popup"
|
||||
role="dialog"
|
||||
:aria-label="t(`common.comments.annotationsTitle`)"
|
||||
:style="state.cardStyle"
|
||||
@keydown.esc.stop="close">
|
||||
<div class="flex items-center gap-2">
|
||||
<w-avatar size="24px" :color="avatarColorFor(openItem.authorId)" text-color="white">
|
||||
<img v-if="openItem.authorHasAvatar" :src="`/_user/${openItem.authorId}/avatar`" alt="" />
|
||||
<span v-else>{{ (openItem.authorName || '?').trim().charAt(0).toUpperCase() }}</span>
|
||||
</w-avatar>
|
||||
<div class="min-w-0 truncate text-body2 font-medium">{{ openItem.authorName }}</div>
|
||||
<div class="shrink-0 text-caption text-grey-6">{{ relativeDate(openItem.createdAt) }}</div>
|
||||
<w-space />
|
||||
<w-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
size="sm"
|
||||
color="grey"
|
||||
icon="la:times"
|
||||
:aria-label="t(`common.actions.close`)"
|
||||
@click="close" />
|
||||
</div>
|
||||
<div
|
||||
class="page-comment-body page-annotation-popup-note"
|
||||
:class="{ 'is-resolved': isResolved }"
|
||||
v-html="renderComment(openItem.annotation.note, state.mentions)" />
|
||||
<div class="page-annotation-popup-state" v-if="isResolved">
|
||||
<w-icon name="la:check-circle" size="1.1em" />
|
||||
{{
|
||||
openItem.annotation.resolvedByName
|
||||
? t('common.comments.annotationResolvedBy', {
|
||||
name: openItem.annotation.resolvedByName
|
||||
})
|
||||
: t('common.comments.annotationResolved')
|
||||
}}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 pt-2">
|
||||
<w-btn
|
||||
flat
|
||||
no-caps
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
color="primary"
|
||||
icon="la:comments"
|
||||
:label="t(`common.comments.annotationViewComment`)"
|
||||
@click="emit(`view-comment`, openItem.commentId)" />
|
||||
<w-space />
|
||||
<w-btn
|
||||
v-if="mayResolve(openItem)"
|
||||
unelevated
|
||||
no-caps
|
||||
size="sm"
|
||||
padding="none sm"
|
||||
:color="isResolved ? `grey` : `positive`"
|
||||
:icon="isResolved ? `la:undo` : `la:check`"
|
||||
:label="
|
||||
isResolved
|
||||
? t(`common.comments.annotationReopen`)
|
||||
: t(`common.comments.annotationResolve`)
|
||||
"
|
||||
:loading="state.busy"
|
||||
@click="toggleResolved" />
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { avatarColorFor } from '@/helpers/avatarColors'
|
||||
import { relativeDate } from '@/helpers/datetime'
|
||||
import {
|
||||
HIGHLIGHTS,
|
||||
indexText,
|
||||
locateAnchor,
|
||||
paintHighlight,
|
||||
placeBeside,
|
||||
rangeContainsPoint,
|
||||
scrollRangeIntoView
|
||||
} from '@/helpers/annotations'
|
||||
import { renderComment } from '@/renderers/comment'
|
||||
|
||||
/**
|
||||
* Every annotation on the page, painted over the article at once: View All Annotations, from the
|
||||
* Talk view's column, and what "Show in the article" beside a single annotation turns on too.
|
||||
*
|
||||
* Mounted by `pages/Index.vue` for as long as it is switched on, whichever view is on screen -- it
|
||||
* stays on until the reader turns it off from the Talk view -- and drawing only while the article is.
|
||||
*
|
||||
* **It loads the discussion itself** rather than borrowing the Talk view's, which is unmounted the
|
||||
* moment the reader comes back to the article. And it loads it again every time they do, since what
|
||||
* they came back from is where annotations are resolved and deleted.
|
||||
*
|
||||
* **A highlight cannot be clicked.** The Custom Highlight API paints a range without putting anything
|
||||
* in the article -- which is the point of it (`helpers/annotations.js`) -- so there is no element for
|
||||
* a click to land on. A click on the article is matched against the boxes of every passage instead,
|
||||
* and the pointer is turned into a hand over one the same way.
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** The element the article was rendered into. */
|
||||
root: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
/** Whether the article is the view on screen. Nothing is painted or open while it is not. */
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** Whether a click on a passage opens its note -- not while passages are being picked for a new comment. */
|
||||
interactive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/** An annotation to bring into view with its note open, as soon as it has been found. */
|
||||
revealId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['revealed', 'view-comment'])
|
||||
|
||||
// STORES
|
||||
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const cardEl = ref(null)
|
||||
|
||||
const state = reactive({
|
||||
loaded: false,
|
||||
/**
|
||||
* One entry per annotation that is still in the article: the annotation, what the popup says about
|
||||
* who made it, and where it is. `range` is `markRaw` for the reason the annotator's are.
|
||||
*/
|
||||
items: [],
|
||||
mentions: [],
|
||||
/** The annotation whose note is open. */
|
||||
openId: null,
|
||||
cardStyle: {},
|
||||
busy: false
|
||||
})
|
||||
|
||||
/** The last frame a hover test was asked for, so a burst of movement costs one test a frame. */
|
||||
let hoverFrame = 0
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const openItem = computed(() => state.items.find((item) => item.annotation.id === state.openId))
|
||||
|
||||
const isResolved = computed(() => Boolean(openItem.value?.annotation.resolvedAt))
|
||||
|
||||
const canModerate = computed(() => userStore.pagePermissions.includes('manage:comments'))
|
||||
const canReview = computed(() => userStore.pagePermissions.includes('review:pages'))
|
||||
const canWrite = computed(() => userStore.pagePermissions.includes('write:comments'))
|
||||
|
||||
// METHODS
|
||||
|
||||
/** As the Talk view decides it (`mayResolve` in `PageTalk.vue`), and as the server checks it. */
|
||||
function mayResolve(item) {
|
||||
if (canModerate.value || canReview.value) {
|
||||
return true
|
||||
}
|
||||
return canWrite.value && Boolean(item.authorId) && item.authorId === userStore.id
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!pageStore.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await API_CLIENT.get(`sites/${siteStore.id}/pages/${pageStore.id}/comments`).json()
|
||||
state.mentions = resp?.mentions ?? []
|
||||
state.items = (resp?.comments ?? []).flatMap((comment) =>
|
||||
(comment.annotations ?? []).map((annotation) => ({
|
||||
annotation,
|
||||
commentId: comment.id,
|
||||
authorId: comment.authorId,
|
||||
authorName: comment.authorName,
|
||||
authorHasAvatar: comment.authorHasAvatar,
|
||||
createdAt: comment.createdAt,
|
||||
range: null
|
||||
}))
|
||||
)
|
||||
// -> Found before saying so: what waits on `loaded` to reveal one needs to know where it is
|
||||
locateAll()
|
||||
state.loaded = true
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.comments.loadFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Find every passage in the article as it stands, with one walk of it for all of them. */
|
||||
function locateAll() {
|
||||
if (!props.root) {
|
||||
return
|
||||
}
|
||||
const index = indexText(props.root)
|
||||
for (const item of state.items) {
|
||||
const range = locateAnchor(index, item.annotation.anchor)
|
||||
item.range = range ? markRaw(range) : null
|
||||
}
|
||||
// -> One whose passage has gone since it was opened has nothing left to be beside
|
||||
if (state.openId && !openItem.value?.range) {
|
||||
state.openId = null
|
||||
}
|
||||
paint()
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint them all, open ones and resolved ones apart, and the one whose note is open as itself rather
|
||||
* than as either -- so it stands out from the rest without two highlights stacked on the same text.
|
||||
*/
|
||||
function paint() {
|
||||
const showing = props.active
|
||||
const others = state.items.filter((item) => item.range && item.annotation.id !== state.openId)
|
||||
paintHighlight(
|
||||
HIGHLIGHTS.shown,
|
||||
showing ? others.filter((i) => !i.annotation.resolvedAt).map((i) => i.range) : []
|
||||
)
|
||||
paintHighlight(
|
||||
HIGHLIGHTS.resolved,
|
||||
showing ? others.filter((i) => i.annotation.resolvedAt).map((i) => i.range) : []
|
||||
)
|
||||
paintHighlight(HIGHLIGHTS.focus, showing && openItem.value ? [openItem.value.range] : [])
|
||||
}
|
||||
|
||||
function unpaint() {
|
||||
for (const name of [HIGHLIGHTS.shown, HIGHLIGHTS.resolved, HIGHLIGHTS.focus]) {
|
||||
paintHighlight(name, [])
|
||||
}
|
||||
}
|
||||
|
||||
/** The annotation at a point on screen. Where passages overlap, the shortest -- the one aimed at. */
|
||||
function itemAt(x, y) {
|
||||
let found = null
|
||||
for (const item of state.items) {
|
||||
if (!item.range || !rangeContainsPoint(item.range, x, y)) {
|
||||
continue
|
||||
}
|
||||
if (!found || item.annotation.anchor.exact.length < found.annotation.anchor.exact.length) {
|
||||
found = item
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function open(item) {
|
||||
state.openId = item.annotation.id
|
||||
paint()
|
||||
nextTick(placeCard)
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (state.openId) {
|
||||
state.openId = null
|
||||
paint()
|
||||
}
|
||||
}
|
||||
|
||||
function placeCard() {
|
||||
if (openItem.value?.range) {
|
||||
state.cardStyle = placeBeside(openItem.value.range, { height: cardEl.value?.offsetHeight })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleResolved() {
|
||||
const item = openItem.value
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
state.busy = true
|
||||
try {
|
||||
const updated = await API_CLIENT.put(
|
||||
`sites/${siteStore.id}/annotations/${item.annotation.id}/resolved`,
|
||||
{ json: { resolved: !item.annotation.resolvedAt } }
|
||||
).json()
|
||||
item.annotation = updated
|
||||
paint()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.comments.annotationUpdateFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.busy = false
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on a passage opens its note, and a click anywhere else in the article closes it. Captured,
|
||||
* so that a passage inside a link opens its note rather than following the link -- the article's own
|
||||
* handler sees a click already taken. A click that ends a selection is the reader selecting text.
|
||||
*/
|
||||
function onArticleClick(ev) {
|
||||
if (!props.active || !props.interactive) {
|
||||
return
|
||||
}
|
||||
if (!(window.getSelection()?.isCollapsed ?? true)) {
|
||||
return
|
||||
}
|
||||
const item = itemAt(ev.clientX, ev.clientY)
|
||||
if (item) {
|
||||
ev.preventDefault()
|
||||
open(item)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Anywhere outside both the article and the note -- the column beside it, the header -- closes it. */
|
||||
function onDocumentPointerDown(ev) {
|
||||
if (!state.openId || cardEl.value?.contains(ev.target) || props.root?.contains(ev.target)) {
|
||||
return
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
function onArticlePointerMove(ev) {
|
||||
if (hoverFrame || !props.active || !props.interactive) {
|
||||
return
|
||||
}
|
||||
const { clientX, clientY } = ev
|
||||
hoverFrame = requestAnimationFrame(() => {
|
||||
hoverFrame = 0
|
||||
// -> An attribute rather than a class: the article's classes are Vue's, and are rewritten whole
|
||||
// whenever its binding changes. Styled in `_page-contents.scss`.
|
||||
props.root?.toggleAttribute('data-annotation-hover', Boolean(itemAt(clientX, clientY)))
|
||||
})
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (state.openId) {
|
||||
placeCard()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bring an annotation into view with its note open. See `revealId`. */
|
||||
function reveal(id) {
|
||||
const item = state.items.find((i) => i.annotation.id === id)
|
||||
if (item?.range) {
|
||||
open(item)
|
||||
scrollRangeIntoView(item.range)
|
||||
// -> The card is placed against where the passage was; it follows as the scroll moves it
|
||||
}
|
||||
emit('revealed')
|
||||
}
|
||||
|
||||
// WATCHERS
|
||||
|
||||
// -> Back on the article: what was resolved or deleted in the discussion is on screen at once
|
||||
watch(
|
||||
() => props.active,
|
||||
(isActive) => {
|
||||
if (isActive) {
|
||||
load()
|
||||
} else {
|
||||
close()
|
||||
paint()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.interactive,
|
||||
(isInteractive) => {
|
||||
if (!isInteractive) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// -> A save re-renders the page, and every range into the old render points at nothing
|
||||
watch(
|
||||
() => pageStore.render,
|
||||
() => locateAll(),
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.revealId, state.loaded, props.active],
|
||||
() => {
|
||||
if (props.revealId && state.loaded && props.active) {
|
||||
reveal(props.revealId)
|
||||
}
|
||||
},
|
||||
// -> After the article is back on screen: a passage in a hidden column has no box to scroll to
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
props.root?.addEventListener('click', onArticleClick, true)
|
||||
props.root?.addEventListener('pointermove', onArticlePointerMove)
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.addEventListener('scroll', onViewportChange, { capture: true, passive: true })
|
||||
window.addEventListener('resize', onViewportChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.root?.removeEventListener('click', onArticleClick, true)
|
||||
props.root?.removeEventListener('pointermove', onArticlePointerMove)
|
||||
props.root?.removeAttribute('data-annotation-hover')
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
|
||||
document.removeEventListener('scroll', onViewportChange, { capture: true })
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
cancelAnimationFrame(hoverFrame)
|
||||
unpaint()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* -> As the annotator's note card, of which this is the reading half */
|
||||
.page-annotation-popup {
|
||||
position: fixed;
|
||||
z-index: 2001;
|
||||
padding: 8px 10px 8px 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
||||
/* -> Stated: teleported to `<body>`, it would otherwise inherit the browser's black in either theme */
|
||||
color: #26292e;
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: $dark-2;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
|
||||
.page-annotation-popup-note {
|
||||
max-height: 240px;
|
||||
padding-top: 8px;
|
||||
overflow-y: auto;
|
||||
|
||||
/* -> Done, as it is drawn in the Talk view */
|
||||
&.is-resolved {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.page-annotation-popup-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--q-positive);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<!--
|
||||
Teleported, so that `position: fixed` means the viewport: the page column is an ancestor that a
|
||||
view transition snapshots, and a fixed box inside anything that ends up with a transform is fixed
|
||||
to that instead.
|
||||
-->
|
||||
<teleport to="body">
|
||||
<transition name="page-annotator-slide" appear>
|
||||
<div
|
||||
ref="panelEl"
|
||||
class="page-annotator"
|
||||
:class="{ 'is-review': state.mode === `review` }"
|
||||
role="region"
|
||||
:aria-label="t(`common.comments.annotationsTitle`)">
|
||||
<!--
|
||||
The toolbar: pick a passage, write a note on it, repeat. Annotate is on when the panel opens
|
||||
and goes off again after each passage, so that a stray drag across the article afterwards --
|
||||
reading, copying a sentence -- is not taken for another annotation.
|
||||
-->
|
||||
<div class="page-annotator-bar" v-if="state.mode === `annotate`">
|
||||
<w-btn
|
||||
no-caps
|
||||
:color="state.picking ? `accent` : `primary`"
|
||||
icon="la:highlighter"
|
||||
unelevated
|
||||
:label="t(`common.comments.annotate`)"
|
||||
:aria-pressed="String(state.picking)"
|
||||
:disable="Boolean(state.draft)"
|
||||
@click="togglePicking" />
|
||||
<div class="page-annotator-hint" aria-live="polite">{{ hint }}</div>
|
||||
<w-space />
|
||||
<w-btn flat no-caps color="grey" :label="t(`common.actions.cancel`)" @click="cancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
no-caps
|
||||
color="primary"
|
||||
icon="la:check"
|
||||
:label="t(`common.comments.annotationReview`)"
|
||||
:disable="state.pending.length < 1 || Boolean(state.draft)"
|
||||
@click="startReview">
|
||||
<span class="page-annotator-count" v-if="state.pending.length > 0">
|
||||
{{ state.pending.length }}
|
||||
</span>
|
||||
</w-btn>
|
||||
</div>
|
||||
<!--
|
||||
The review: one comment about the page as a whole, then each passage with its note, then
|
||||
the way out in either direction. Built on the same box every other comment is written in, so
|
||||
the general comment has the preview and the `@` completion the others have.
|
||||
-->
|
||||
<div class="page-annotator-review" v-else>
|
||||
<div class="text-subtitle2 pb-2">{{ t('common.comments.annotationReviewTitle') }}</div>
|
||||
<page-comment-editor
|
||||
ref="commentEditor"
|
||||
v-model="state.comment"
|
||||
allow-empty
|
||||
cancelable
|
||||
:rows="3"
|
||||
:busy="state.busy"
|
||||
:placeholder="t(`common.comments.annotationGeneralPlaceholder`)"
|
||||
:submit-label="t(`common.comments.postWithAnnotations`)"
|
||||
:cancel-label="t(`common.comments.annotationGoBack`)"
|
||||
@submit="post"
|
||||
@cancel="backToAnnotating">
|
||||
<div class="text-caption text-grey-6 pt-3 pb-1">
|
||||
{{ t('common.comments.annotationsCount', state.pending.length) }}
|
||||
</div>
|
||||
<ol class="page-annotator-list">
|
||||
<li v-for="(item, idx) of state.pending" :key="item.key" class="page-annotator-item">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="page-annotation-quote">{{ item.anchor.exact }}</div>
|
||||
<div class="page-comment-body" v-html="renderComment(item.note)" />
|
||||
</div>
|
||||
<w-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
size="sm"
|
||||
color="grey"
|
||||
icon="la:crosshairs"
|
||||
:aria-label="t(`common.comments.annotationLocate`)"
|
||||
@click="showPending(item)">
|
||||
<w-tooltip>{{ t('common.comments.annotationLocate') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
size="sm"
|
||||
color="negative"
|
||||
icon="la:trash"
|
||||
:aria-label="t(`common.comments.annotationRemove`)"
|
||||
:disable="state.busy"
|
||||
@click="removePending(idx)">
|
||||
<w-tooltip>{{ t('common.comments.annotationRemove') }}</w-tooltip>
|
||||
</w-btn>
|
||||
</li>
|
||||
</ol>
|
||||
</page-comment-editor>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<!--
|
||||
The note on the passage just picked, beside it. Fixed rather than in the article's flow so that
|
||||
nothing in the article moves while it is open -- it is positioned off the passage's own boxes,
|
||||
and follows them as the column scrolls.
|
||||
-->
|
||||
<div
|
||||
v-if="state.draft"
|
||||
ref="cardEl"
|
||||
class="page-annotator-card"
|
||||
:style="state.cardStyle"
|
||||
@keydown.esc.stop="discardDraft">
|
||||
<w-input
|
||||
ref="noteInput"
|
||||
type="textarea"
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
:rows="3"
|
||||
:placeholder="t(`common.comments.annotationNotePlaceholder`)"
|
||||
:aria-label="t(`common.comments.annotationNotePlaceholder`)"
|
||||
v-model="state.draft.note"
|
||||
@keydown.ctrl.enter="commitDraft"
|
||||
@keydown.meta.enter="commitDraft" />
|
||||
<div class="flex justify-end gap-1 pt-2">
|
||||
<w-btn
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
size="sm"
|
||||
padding="none sm"
|
||||
color="grey"
|
||||
:label="t(`common.actions.discard`)"
|
||||
@click="discardDraft" />
|
||||
<w-btn
|
||||
unelevated
|
||||
dense
|
||||
no-caps
|
||||
size="sm"
|
||||
padding="none sm"
|
||||
color="primary"
|
||||
:label="t(`common.actions.ok`)"
|
||||
:disable="!draftIsValid"
|
||||
@click="commitDraft" />
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { confirm } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import {
|
||||
HIGHLIGHTS,
|
||||
QUOTE_MAX_LENGTH,
|
||||
describeRange,
|
||||
indexText,
|
||||
paintHighlight,
|
||||
placeBeside,
|
||||
scrollRangeIntoView
|
||||
} from '@/helpers/annotations'
|
||||
import { renderComment } from '@/renderers/comment'
|
||||
|
||||
import PageCommentEditor from '@/components/PageCommentEditor.vue'
|
||||
|
||||
/**
|
||||
* Annotating the article: picking passages of it, a note on each, and posting them as one comment.
|
||||
*
|
||||
* Mounted by `pages/Index.vue` over the article, from the New Annotation button beside the Talk
|
||||
* view, and unmounted when the reader posts, cancels, or goes anywhere else -- nothing here is kept
|
||||
* between two visits, since the passages are live ranges into one render of one page.
|
||||
*
|
||||
* **Nothing is written into the article.** A picked passage is described (`helpers/annotations.js`)
|
||||
* and painted with the CSS Custom Highlight API, so the text the second passage is picked from is
|
||||
* exactly the text the first one was. Where the browser has no such API the passages are simply not
|
||||
* coloured; everything else works.
|
||||
*
|
||||
* Signed-in readers only, which is the caller's check: an annotation is resolved later by whoever
|
||||
* posted it, and a guest has no session to be recognized by.
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** The element the article was rendered into -- what passages may be picked from. */
|
||||
root: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'posted'])
|
||||
|
||||
/** As `ANNOTATION_NOTE_MAX_LENGTH` on the server. */
|
||||
const NOTE_MAX_LENGTH = 2000
|
||||
|
||||
// STORES
|
||||
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const panelEl = ref(null)
|
||||
const cardEl = ref(null)
|
||||
const noteInput = ref(null)
|
||||
const commentEditor = ref(null)
|
||||
|
||||
let nextKey = 0
|
||||
|
||||
const state = reactive({
|
||||
/** `annotate` while passages are being picked, `review` once the reader has asked to review them. */
|
||||
mode: 'annotate',
|
||||
/** Whether a drag across the article picks a passage. */
|
||||
picking: true,
|
||||
/**
|
||||
* The passage just picked, with its note still being written. Its range is `markRaw`: a DOM object
|
||||
* behind a reactive proxy has its methods called with the proxy as `this`, which the browser
|
||||
* refuses.
|
||||
*/
|
||||
draft: null,
|
||||
/** Picked and noted: `{ key, anchor, range, note }`, in the order they were picked. */
|
||||
pending: [],
|
||||
/** The comment on the page as a whole, written in the review. */
|
||||
comment: '',
|
||||
busy: false,
|
||||
cardStyle: {}
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const hint = computed(() => {
|
||||
if (state.draft) {
|
||||
return t('common.comments.annotateNoteHint')
|
||||
}
|
||||
return state.picking ? t('common.comments.annotateHint') : t('common.comments.annotateIdleHint')
|
||||
})
|
||||
|
||||
/** A note is required, and held to the server's limit -- the box has no cap of its own to stop at. */
|
||||
const draftIsValid = computed(() => {
|
||||
const length = state.draft?.note.trim().length ?? 0
|
||||
return length > 0 && length <= NOTE_MAX_LENGTH
|
||||
})
|
||||
|
||||
/** Whether leaving now would throw away something the reader wrote. */
|
||||
const hasWork = computed(() => state.pending.length > 0 || Boolean(state.draft))
|
||||
|
||||
// METHODS
|
||||
|
||||
function paint() {
|
||||
paintHighlight(
|
||||
HIGHLIGHTS.pending,
|
||||
state.pending.map((item) => item.range)
|
||||
)
|
||||
paintHighlight(HIGHLIGHTS.draft, state.draft ? [state.draft.range] : [])
|
||||
}
|
||||
|
||||
function togglePicking() {
|
||||
state.picking = !state.picking
|
||||
}
|
||||
|
||||
/**
|
||||
* A selection only means something once it is finished, which is when the pointer comes up -- or,
|
||||
* for somebody selecting from the keyboard, when Shift does. The selection itself settles after the
|
||||
* event, hence the turn of the loop before reading it.
|
||||
*/
|
||||
function onPointerUp(ev) {
|
||||
if (!state.picking || state.draft || state.mode !== 'annotate') {
|
||||
return
|
||||
}
|
||||
if (panelEl.value?.contains(ev.target)) {
|
||||
return
|
||||
}
|
||||
setTimeout(pickSelection, 0)
|
||||
}
|
||||
|
||||
function onKeyUp(ev) {
|
||||
if (ev.key === 'Shift') {
|
||||
onPointerUp(ev)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's selection, cut down to the article.
|
||||
*
|
||||
* A drag that runs past the end of the article into what follows it is still a passage of the
|
||||
* article -- the overshoot is cut off rather than the whole drag refused.
|
||||
*/
|
||||
function selectedRange() {
|
||||
const selection = window.getSelection()
|
||||
if (!props.root || !selection || selection.rangeCount < 1 || selection.isCollapsed) {
|
||||
return null
|
||||
}
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
const article = document.createRange()
|
||||
article.selectNodeContents(props.root)
|
||||
if (range.compareBoundaryPoints(Range.START_TO_START, article) < 0) {
|
||||
range.setStart(article.startContainer, article.startOffset)
|
||||
}
|
||||
if (range.compareBoundaryPoints(Range.END_TO_END, article) > 0) {
|
||||
range.setEnd(article.endContainer, article.endOffset)
|
||||
}
|
||||
return range.collapsed ? null : range
|
||||
}
|
||||
|
||||
function pickSelection() {
|
||||
const range = selectedRange()
|
||||
if (!range) {
|
||||
return
|
||||
}
|
||||
const described = describeRange(indexText(props.root), range)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
if (!described) {
|
||||
return
|
||||
}
|
||||
if (described.anchor.exact.length > QUOTE_MAX_LENGTH) {
|
||||
notify({ type: 'warning', message: t('common.comments.annotationTooLong') })
|
||||
return
|
||||
}
|
||||
state.draft = { anchor: described.anchor, range: markRaw(described.range), note: '' }
|
||||
paint()
|
||||
nextTick(() => {
|
||||
placeCard()
|
||||
noteInput.value?.focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the note card to the right of the passage's last line, or under it where the window has no
|
||||
* room on the right -- a passage ending near the far edge of the column, or any passage on a phone.
|
||||
* Kept inside the window and clear of the panel at the bottom either way.
|
||||
*/
|
||||
function placeCard() {
|
||||
if (!state.draft) {
|
||||
return
|
||||
}
|
||||
state.cardStyle = placeBeside(state.draft.range, {
|
||||
height: cardEl.value?.offsetHeight,
|
||||
floor: panelEl.value?.getBoundingClientRect().top
|
||||
})
|
||||
}
|
||||
|
||||
function commitDraft() {
|
||||
if (!draftIsValid.value) {
|
||||
return
|
||||
}
|
||||
state.pending.push({ ...state.draft, key: nextKey++, note: state.draft.note.trim() })
|
||||
state.draft = null
|
||||
state.picking = false
|
||||
paint()
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
state.draft = null
|
||||
state.picking = false
|
||||
paint()
|
||||
}
|
||||
|
||||
function startReview() {
|
||||
state.picking = false
|
||||
state.mode = 'review'
|
||||
nextTick(() => commentEditor.value?.focus())
|
||||
}
|
||||
|
||||
function backToAnnotating() {
|
||||
state.mode = 'annotate'
|
||||
}
|
||||
|
||||
function showPending(item) {
|
||||
scrollRangeIntoView(item.range)
|
||||
}
|
||||
|
||||
function removePending(idx) {
|
||||
state.pending.splice(idx, 1)
|
||||
paint()
|
||||
// -> Nothing left to review is nothing left to post as annotations: back to picking some
|
||||
if (state.pending.length < 1) {
|
||||
state.mode = 'annotate'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask before throwing away what the reader has picked, if they have picked anything.
|
||||
*
|
||||
* @returns {Promise<boolean>} Whether it is fine to go.
|
||||
*/
|
||||
function confirmDiscard() {
|
||||
if (!hasWork.value) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
confirm({
|
||||
title: t('common.comments.annotationCancelTitle'),
|
||||
message: t('common.comments.annotationCancelWarn'),
|
||||
cancel: true,
|
||||
color: 'negative',
|
||||
okLabel: t('common.actions.discard')
|
||||
})
|
||||
.onOk(() => resolve(true))
|
||||
.onCancel(() => resolve(false))
|
||||
})
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (await confirmDiscard()) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
async function post() {
|
||||
state.busy = true
|
||||
try {
|
||||
const comment = await API_CLIENT.post(`sites/${siteStore.id}/pages/${pageStore.id}/comments`, {
|
||||
json: {
|
||||
content: state.comment,
|
||||
annotations: state.pending.map(({ note, anchor }) => ({ note, anchor }))
|
||||
}
|
||||
}).json()
|
||||
// -> Emptied first, so that the page being left does not ask whether to throw them away
|
||||
state.pending = []
|
||||
notify({ type: 'positive', message: t('common.comments.postSuccess') })
|
||||
emit('posted', comment.id)
|
||||
} catch (err) {
|
||||
// -> In full, as for any other comment: a cooldown or a spam refusal is something to act on
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.comments.postFailed'),
|
||||
caption: apiErrorMessage(err),
|
||||
timeout: 10000
|
||||
})
|
||||
}
|
||||
state.busy = false
|
||||
}
|
||||
|
||||
/**
|
||||
* A link in the article is text to be picked while picking, not somewhere to go: a drag that starts
|
||||
* on one ends in a click on it. Captured, so the article's own handler sees a click already taken.
|
||||
*/
|
||||
function onArticleClick(ev) {
|
||||
if ((state.picking || state.draft) && ev.target?.closest?.('a')) {
|
||||
ev.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
if (state.draft) {
|
||||
placeCard()
|
||||
}
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
// -> Captured: the article scrolls in a box of its own, and a scroll does not bubble
|
||||
document.addEventListener('scroll', onViewportChange, { capture: true, passive: true })
|
||||
window.addEventListener('resize', onViewportChange)
|
||||
props.root?.addEventListener('click', onArticleClick, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
document.removeEventListener('scroll', onViewportChange, { capture: true })
|
||||
window.removeEventListener('resize', onViewportChange)
|
||||
props.root?.removeEventListener('click', onArticleClick, true)
|
||||
// -> Its own two only: with View All Annotations on, the rest of the page's are still showing
|
||||
paintHighlight(HIGHLIGHTS.pending, [])
|
||||
paintHighlight(HIGHLIGHTS.draft, [])
|
||||
})
|
||||
|
||||
// EXPOSED
|
||||
|
||||
/* -> For `pages/Index.vue`, which draws the article's cursor and asks before the reader leaves */
|
||||
defineExpose({
|
||||
isPicking: computed(() => state.mode === 'annotate' && state.picking && !state.draft),
|
||||
confirmDiscard
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/*
|
||||
The panel: a sheet pulled up from the bottom edge of the window, as wide as a comfortable line of
|
||||
text and no wider, so that the article on either side of it stays in view while passages are picked.
|
||||
*/
|
||||
.page-annotator {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: min(760px, 100vw - 32px);
|
||||
translate: -50% 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 0;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.16);
|
||||
color: #26292e;
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background-color: $dark-2;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-xs-max) {
|
||||
width: 100vw;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
&.is-review {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-annotator-slide-enter-active,
|
||||
.page-annotator-slide-leave-active {
|
||||
transition: transform 0.25s var(--ease-standard);
|
||||
}
|
||||
|
||||
.page-annotator-slide-enter-from,
|
||||
.page-annotator-slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.page-annotator-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-annotator-hint {
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
|
||||
@media (max-width: $breakpoint-xs-max) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-annotator-count {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 0.6875rem;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*
|
||||
The review grows out of the toolbar rather than replacing it in a cut: the same sheet, pulled
|
||||
further up. Capped so the passages being reviewed can still be seen above it, and scrolling inside
|
||||
itself beyond that.
|
||||
*/
|
||||
.page-annotator-review {
|
||||
max-height: min(70vh, 640px);
|
||||
overflow-y: auto;
|
||||
animation: page-annotator-expand 0.25s var(--ease-standard);
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-annotator-expand {
|
||||
from {
|
||||
max-height: 44px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
max-height: min(70vh, 640px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.page-annotator-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.page-annotator-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The note card, beside the passage it is about. Above the panel in the stacking order: near the
|
||||
bottom of the window the two can meet, and the card is what the reader is typing in.
|
||||
*/
|
||||
.page-annotator-card {
|
||||
position: fixed;
|
||||
z-index: 2001;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
||||
/*
|
||||
Stated, as the panel's is: teleported to `<body>`, the card inherits nothing but the browser's own
|
||||
black -- which is what the note box typed in on the dark surface, and could not be read.
|
||||
*/
|
||||
color: #26292e;
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: $dark-2;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
While picking: the article reads as text to be selected, links included, which is the whole of what
|
||||
a click on it does until a passage is picked. Room is kept below the last paragraph for the panel,
|
||||
so that the end of the article can still be scrolled clear of it.
|
||||
*/
|
||||
.page-article-col.is-annotating .page-container-body {
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.page-article-col.is-picking .page-contents {
|
||||
&,
|
||||
* {
|
||||
cursor: text !important;
|
||||
}
|
||||
|
||||
a,
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +1,287 @@
|
||||
<template>
|
||||
<div class="page-comment" :class="{ 'is-reply': Boolean(comment.parentId) }">
|
||||
<div class="page-comment-avatar">
|
||||
<w-avatar :size="comment.parentId ? `28px` : `36px`" :color="avatarColor" text-color="white">
|
||||
<img v-if="comment.authorHasAvatar" :src="`/_user/${comment.authorId}/avatar`" alt="" />
|
||||
<span v-else>{{ initial }}</span>
|
||||
</w-avatar>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2">
|
||||
<!--
|
||||
<!-- -> The id is what the view scrolls to once a comment has been posted from the annotator -->
|
||||
<div
|
||||
class="page-comment"
|
||||
:id="`comment-${comment.id}`"
|
||||
:class="{ 'is-reply': Boolean(comment.parentId), 'is-deleted': comment.isDeleted }">
|
||||
<!--
|
||||
What is left of a deleted comment that has replies: a note in its place, so the replies under it
|
||||
still read as answers to something. Nothing of the comment is served to draw here -- no author,
|
||||
no text. The one action is a moderator's: taking the whole thread, which is all that is left of
|
||||
it to delete.
|
||||
-->
|
||||
<div class="page-comment-main">
|
||||
<template v-if="comment.isDeleted">
|
||||
<div class="page-comment-avatar">
|
||||
<w-avatar size="36px" color="grey-3" text-color="grey-6">
|
||||
<w-icon name="la:comment-slash" />
|
||||
</w-avatar>
|
||||
</div>
|
||||
<div class="page-comment-deleted">{{ t('common.comments.deleted') }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="page-comment-avatar">
|
||||
<w-avatar
|
||||
:size="comment.parentId ? `28px` : `36px`"
|
||||
:color="avatarColor"
|
||||
text-color="white">
|
||||
<img v-if="comment.authorHasAvatar" :src="`/_user/${comment.authorId}/avatar`" alt="" />
|
||||
<span v-else>{{ initial }}</span>
|
||||
</w-avatar>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2">
|
||||
<!--
|
||||
A link only where there is a profile to open. A guest has no account behind the name, and
|
||||
neither has a comment whose author was deleted -- in both cases the name is a copy the row
|
||||
kept, and nothing is there to link to.
|
||||
-->
|
||||
<router-link
|
||||
v-if="comment.authorId"
|
||||
class="text-body2 font-medium page-comment-author"
|
||||
:to="`/_user/${comment.authorId}`">
|
||||
{{ comment.authorName }}
|
||||
</router-link>
|
||||
<span class="text-body2 font-medium" v-else>{{ comment.authorName }}</span>
|
||||
<span class="text-caption text-grey-6" v-if="comment.authorHandle">
|
||||
@{{ comment.authorHandle }}
|
||||
</span>
|
||||
<w-chip v-if="comment.isGuest" size="xs" color="grey-4" text-color="grey-8">
|
||||
{{ t('common.comments.guest') }}
|
||||
</w-chip>
|
||||
<span class="text-caption text-grey-6">{{ relativeDate(comment.createdAt) }}</span>
|
||||
<!-- -> Only when it is actually true of this comment, and without repeating the date: what
|
||||
a reader needs to know is that what they are reading is not what was first posted -->
|
||||
<span class="text-caption text-grey-6" v-if="wasEdited">
|
||||
· {{ t('common.comments.edited') }}
|
||||
</span>
|
||||
</div>
|
||||
<page-comment-editor
|
||||
class="pt-2"
|
||||
v-if="editing"
|
||||
v-model="draft"
|
||||
cancelable
|
||||
:rows="3"
|
||||
:busy="busy"
|
||||
:submit-label="t(`common.comments.updateComment`)"
|
||||
@submit="$emit(`save`, { id: comment.id, content: draft })"
|
||||
@cancel="$emit(`cancel-edit`)" />
|
||||
<template v-else>
|
||||
<!--
|
||||
<router-link
|
||||
v-if="comment.authorId"
|
||||
class="text-body2 font-medium page-comment-author"
|
||||
:to="`/_user/${comment.authorId}`">
|
||||
{{ comment.authorName }}
|
||||
</router-link>
|
||||
<span class="text-body2 font-medium" v-else>{{ comment.authorName }}</span>
|
||||
<span class="text-caption text-grey-6" v-if="comment.authorHandle">
|
||||
@{{ comment.authorHandle }}
|
||||
</span>
|
||||
<w-chip v-if="comment.isGuest" size="xs" color="grey-4" text-color="grey-8">
|
||||
{{ t('common.comments.guest') }}
|
||||
</w-chip>
|
||||
<!--
|
||||
When, at the far end of the line: who said it is what the eye looks for first, and the
|
||||
date is what it checks afterwards. Relative, with the moment itself on hover -- "3 days
|
||||
ago" is what a discussion is read by, and the exact time is what settles an argument
|
||||
about which came first.
|
||||
-->
|
||||
<span class="page-comment-when text-caption text-grey-6">
|
||||
<time :datetime="comment.createdAt">
|
||||
{{ relativeDate(comment.createdAt) }}
|
||||
<w-tooltip>{{ fullDate }}</w-tooltip>
|
||||
</time>
|
||||
<!-- -> Only when it is actually true of this comment, and without repeating the date: what
|
||||
a reader needs to know is that what they are reading is not what was first posted -->
|
||||
<span v-if="wasEdited"> · {{ t('common.comments.edited') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<page-comment-editor
|
||||
class="pt-2"
|
||||
v-if="editing"
|
||||
v-model="draft"
|
||||
cancelable
|
||||
:allow-empty="hasAnnotations"
|
||||
:rows="3"
|
||||
:busy="busy"
|
||||
:submit-label="t(`common.comments.updateComment`)"
|
||||
@submit="$emit(`save`, { id: comment.id, content: draft })"
|
||||
@cancel="$emit(`cancel-edit`)" />
|
||||
<template v-else>
|
||||
<!--
|
||||
`v-html` on output this app rendered a moment ago, from markdown with raw HTML disabled --
|
||||
see `renderers/comment.js`, where that is the whole security boundary. Nothing stored is
|
||||
HTML, so there is no older sanitizer's work being trusted here.
|
||||
-->
|
||||
<div class="page-comment-body" v-html="rendered" />
|
||||
<div class="flex flex-wrap items-center gap-1 pt-1">
|
||||
<w-btn
|
||||
v-if="canReply"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="primary"
|
||||
icon="la:reply"
|
||||
:label="t(`common.comments.reply`)"
|
||||
@click="$emit(`reply`, comment)" />
|
||||
<w-btn
|
||||
v-if="canEdit"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="grey"
|
||||
icon="la:pen"
|
||||
:label="t(`common.actions.edit`)"
|
||||
@click="$emit(`edit`, comment)" />
|
||||
<w-btn
|
||||
v-if="canDelete"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="grey"
|
||||
icon="la:trash"
|
||||
:label="t(`common.actions.delete`)"
|
||||
@click="$emit(`delete`, comment)" />
|
||||
<div class="page-comment-body" v-html="rendered" />
|
||||
<!--
|
||||
The passages of the article this comment is about, each with its note. A resolved one stays,
|
||||
dimmed and struck through: it is part of what was said, and the thread may still be talking
|
||||
about it.
|
||||
-->
|
||||
<ol class="page-comment-annotations" v-if="hasAnnotations">
|
||||
<li
|
||||
v-for="annotation of comment.annotations"
|
||||
:key="annotation.id"
|
||||
class="page-comment-annotation"
|
||||
:class="{ 'is-resolved': Boolean(annotation.resolvedAt) }">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="page-annotation-quote">{{ annotation.anchor.exact }}</div>
|
||||
<div v-if="editingAnnotationId === annotation.id">
|
||||
<w-input
|
||||
type="textarea"
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
:rows="2"
|
||||
:aria-label="t(`common.comments.annotationNotePlaceholder`)"
|
||||
:disable="annotationBusy === annotation.id"
|
||||
v-model="noteDraft" />
|
||||
<div class="flex justify-end gap-1 pt-1">
|
||||
<w-btn
|
||||
flat
|
||||
no-caps
|
||||
size="sm"
|
||||
padding="none sm"
|
||||
color="grey"
|
||||
:label="t(`common.actions.cancel`)"
|
||||
:disable="annotationBusy === annotation.id"
|
||||
@click="$emit(`cancel-annotation-edit`)" />
|
||||
<w-btn
|
||||
unelevated
|
||||
no-caps
|
||||
size="sm"
|
||||
padding="none sm"
|
||||
color="primary"
|
||||
:label="t(`common.actions.save`)"
|
||||
:loading="annotationBusy === annotation.id"
|
||||
:disable="noteDraft.trim().length < 1"
|
||||
@click="$emit(`save-annotation`, { id: annotation.id, note: noteDraft })" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
class="page-comment-body page-comment-annotation-note"
|
||||
v-html="renderComment(annotation.note, mentions)" />
|
||||
<div class="flex flex-wrap items-center gap-1 pt-1">
|
||||
<span
|
||||
class="page-comment-annotation-state text-caption"
|
||||
v-if="annotation.resolvedAt">
|
||||
<w-icon name="la:check-circle" size="1.1em" />
|
||||
{{
|
||||
annotation.resolvedByName
|
||||
? t('common.comments.annotationResolvedBy', {
|
||||
name: annotation.resolvedByName
|
||||
})
|
||||
: t('common.comments.annotationResolved')
|
||||
}}
|
||||
</span>
|
||||
<w-btn
|
||||
v-if="canResolve"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
:color="annotation.resolvedAt ? `grey` : `positive`"
|
||||
:icon="annotation.resolvedAt ? `la:undo` : `la:check`"
|
||||
:label="
|
||||
annotation.resolvedAt
|
||||
? t(`common.comments.annotationReopen`)
|
||||
: t(`common.comments.annotationResolve`)
|
||||
"
|
||||
:disable="annotationBusy === annotation.id"
|
||||
@click="toggleResolved(annotation)" />
|
||||
<w-btn
|
||||
v-if="canEditNotes"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="grey"
|
||||
icon="la:pen"
|
||||
:label="t(`common.actions.edit`)"
|
||||
:disable="annotationBusy === annotation.id"
|
||||
@click="$emit(`edit-annotation`, annotation)" />
|
||||
<w-btn
|
||||
v-if="canResolve"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="grey"
|
||||
icon="la:trash"
|
||||
:label="t(`common.actions.delete`)"
|
||||
:disable="annotationBusy === annotation.id"
|
||||
@click="$emit(`delete-annotation`, annotation)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!--
|
||||
Over to the passage in the article. A different icon where the passage can no longer be
|
||||
found, decided before the click rather than after it, so a reader can see at a glance
|
||||
which remarks are about text that has since changed.
|
||||
-->
|
||||
<w-btn
|
||||
class="page-comment-annotation-goto"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
size="sm"
|
||||
:color="isLost(annotation) ? `grey` : `primary`"
|
||||
:icon="isLost(annotation) ? `la:unlink` : `la:arrow-right`"
|
||||
:aria-label="
|
||||
isLost(annotation)
|
||||
? t(`common.comments.annotationLostTitle`)
|
||||
: t(`common.comments.annotationGoTo`)
|
||||
"
|
||||
@click="$emit(`locate-annotation`, annotation)">
|
||||
<w-tooltip anchor="center left" self="center right">
|
||||
{{
|
||||
isLost(annotation)
|
||||
? t('common.comments.annotationLostTitle')
|
||||
: t('common.comments.annotationGoTo')
|
||||
}}
|
||||
</w-tooltip>
|
||||
</w-btn>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!--
|
||||
What can be done with the comment, in a bar of its own along the bottom of the card rather than
|
||||
under the text: the text is what is being read, and the controls are furniture around it. Not
|
||||
drawn at all where there is nothing to offer, nor while the comment is being edited -- the box
|
||||
in its place carries its own buttons then.
|
||||
-->
|
||||
<div class="page-comment-footer" v-if="hasActions">
|
||||
<w-btn
|
||||
v-if="comment.isDeleted"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="grey"
|
||||
icon="la:trash"
|
||||
:label="t(`common.comments.deleteThread`)"
|
||||
:disable="busy"
|
||||
@click="$emit(`delete-thread`, comment)" />
|
||||
<!--
|
||||
Reply at the far right, set apart from the two that change the comment -- answering is what
|
||||
the bar is mostly for, and Edit and Delete are a reader's own business or a moderator's. Those
|
||||
two are icons alone, with the word in a tooltip and for a screen reader.
|
||||
-->
|
||||
<template v-else>
|
||||
<w-btn
|
||||
v-if="canEdit"
|
||||
size="sm"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="grey"
|
||||
icon="la:pen"
|
||||
:aria-label="t(`common.actions.edit`)"
|
||||
@click="$emit(`edit`, comment)">
|
||||
<w-tooltip>{{ t('common.actions.edit') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn
|
||||
v-if="canDelete"
|
||||
size="sm"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
color="grey"
|
||||
icon="la:trash"
|
||||
:aria-label="t(`common.actions.delete`)"
|
||||
@click="$emit(`delete`, comment)">
|
||||
<w-tooltip>{{ t('common.actions.delete') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-separator
|
||||
v-if="canReply && (canEdit || canDelete)"
|
||||
vertical
|
||||
class="page-comment-footer-sep" />
|
||||
<w-btn
|
||||
v-if="canReply"
|
||||
size="sm"
|
||||
padding="none xs"
|
||||
flat
|
||||
no-caps
|
||||
color="primary"
|
||||
icon="la:reply"
|
||||
:label="t(`common.comments.reply`)"
|
||||
@click="$emit(`reply`, comment)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -126,10 +324,43 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** On a deleted comment's placeholder: may take the whole thread under it. A moderator's call. */
|
||||
canDeleteThread: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Which of its annotations can still be found in the article, by id. One missing from the map has
|
||||
* not been looked for yet, and is offered as findable -- the click looks again either way.
|
||||
*/
|
||||
annotationsFound: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
/** May resolve, reopen and delete its annotations: the author, `review:pages` or `manage:comments`. */
|
||||
canResolve: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** May rewrite the notes of its annotations -- the same people who may edit the comment. */
|
||||
canEditNotes: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** The annotation whose note is being edited, if it is one of these. Owned by the list, as `editing` is. */
|
||||
editingAnnotationId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/** The annotation a request is out for, if it is one of these. */
|
||||
annotationBusy: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* Whether this comment is the one being edited.
|
||||
*
|
||||
@@ -142,7 +373,20 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['reply', 'edit', 'cancel-edit', 'save', 'delete'])
|
||||
const emit = defineEmits([
|
||||
'reply',
|
||||
'edit',
|
||||
'cancel-edit',
|
||||
'save',
|
||||
'delete',
|
||||
'delete-thread',
|
||||
'locate-annotation',
|
||||
'resolve-annotation',
|
||||
'edit-annotation',
|
||||
'cancel-annotation-edit',
|
||||
'save-annotation',
|
||||
'delete-annotation'
|
||||
])
|
||||
|
||||
// I18N
|
||||
|
||||
@@ -151,11 +395,34 @@ const { t } = useI18n()
|
||||
// DATA
|
||||
|
||||
const draft = ref('')
|
||||
/** The note of the annotation being edited, as it is being rewritten. */
|
||||
const noteDraft = ref('')
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const rendered = computed(() => renderComment(props.comment.content, props.mentions))
|
||||
|
||||
/**
|
||||
* The moment it was posted, in full, for the tooltip on the relative date. In the reader's own locale,
|
||||
* as the relative date beside it is (`helpers/datetime.js`), so the two read as one statement.
|
||||
*/
|
||||
const fullDate = computed(() =>
|
||||
Temporal.Instant.from(props.comment.createdAt).toLocaleString(undefined, {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
)
|
||||
|
||||
const hasAnnotations = computed(() => (props.comment.annotations?.length ?? 0) > 0)
|
||||
|
||||
/** Whether the footer has anything in it. See the template. */
|
||||
const hasActions = computed(() => {
|
||||
if (props.comment.isDeleted) {
|
||||
return props.canDeleteThread
|
||||
}
|
||||
return !props.editing && (props.canReply || props.canEdit || props.canDelete)
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether this comment has been changed since it was posted.
|
||||
*
|
||||
@@ -184,8 +451,28 @@ const avatarColor = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/** Whether an annotation's passage was looked for in the article and not found. */
|
||||
function isLost(annotation) {
|
||||
return props.annotationsFound[annotation.id] === false
|
||||
}
|
||||
|
||||
function toggleResolved(annotation) {
|
||||
emit('resolve-annotation', { annotation, resolved: !annotation.resolvedAt })
|
||||
}
|
||||
|
||||
// WATCHERS
|
||||
|
||||
// -> As for the comment itself: filled from the note as it stands the moment the box opens
|
||||
watch(
|
||||
() => props.editingAnnotationId,
|
||||
(id) => {
|
||||
noteDraft.value = props.comment.annotations?.find((a) => a.id === id)?.note ?? ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// -> The box is filled from the comment as it stands the moment it opens, and emptied when it closes
|
||||
// so that re-opening it never shows a draft from an edit that was abandoned
|
||||
watch(
|
||||
@@ -212,12 +499,17 @@ watch(
|
||||
--reply-indent: 48px;
|
||||
--thread-line: #{$grey-4};
|
||||
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px var(--comment-pad-x);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
/*
|
||||
Lifted off the Talk view's grey, just: a tight layer for the edge and a soft one for the drop, both
|
||||
faint. Not `--shadow-card`, which is the elevation of a panel -- a page of discussion is a stack
|
||||
of these, and that much shadow on every one of them reads as a pile rather than a column.
|
||||
*/
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.04),
|
||||
0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
color: #26292e;
|
||||
|
||||
@at-root .body--dark & {
|
||||
@@ -227,6 +519,10 @@ watch(
|
||||
--thread-line: #{color-mix(in srgb, #fff 14%, $dark-3)};
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
background-color: $dark-2;
|
||||
/* -> Deeper, since a faint black on a dark ground is nothing at all */
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.3),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
}
|
||||
|
||||
@@ -281,11 +577,50 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.page-comment-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px var(--comment-pad-x);
|
||||
}
|
||||
|
||||
/*
|
||||
The actions, along the bottom of the card and gathered at its right end. A shade off the card's own
|
||||
surface and a hairline above, so it reads as the card's footer rather than as more of the comment;
|
||||
its corners follow the card's, less the border, since the card cannot clip it -- a reply draws its
|
||||
thread line outside itself.
|
||||
*/
|
||||
.page-comment-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 3px calc(var(--comment-pad-x) - 6px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 0 0 7px 7px;
|
||||
background-color: rgba(0, 0, 0, 0.025);
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-top-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.page-comment-footer-sep {
|
||||
align-self: stretch;
|
||||
margin: 4px 6px;
|
||||
}
|
||||
|
||||
.page-comment-avatar {
|
||||
flex: none;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.page-comment-when {
|
||||
margin-inline-start: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-comment-author {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@@ -384,4 +719,72 @@ watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The note a deleted comment leaves for its replies: as tall as the avatar beside it and no taller,
|
||||
in grey, so the thread reads on past it. The avatar is a muted circle rather than nothing, because
|
||||
the line down to the replies is drawn from where an avatar sits.
|
||||
*/
|
||||
.page-comment-deleted {
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
color: $grey-6;
|
||||
}
|
||||
|
||||
.page-comment.is-deleted .w-avatar {
|
||||
@at-root .body--dark & {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
color: rgba(255, 255, 255, 0.4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A comment's annotations, under its text: each passage quoted, the note on it, and the way over to it
|
||||
in the article at the far end. Divided by rules rather than boxed, since they are parts of one card.
|
||||
*/
|
||||
.page-comment-annotations {
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.page-comment-annotation {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.07);
|
||||
|
||||
@at-root .body--dark & {
|
||||
border-top-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
/*
|
||||
Done, and still there to be read: dimmed, with what was quoted and what was said struck through.
|
||||
The controls and the line saying who resolved it are not struck -- they are about the annotation,
|
||||
not part of it.
|
||||
*/
|
||||
&.is-resolved {
|
||||
.page-annotation-quote,
|
||||
.page-comment-annotation-note {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-comment-annotation-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline-end: 4px;
|
||||
color: var(--q-positive);
|
||||
}
|
||||
|
||||
.page-comment-annotation-goto {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -99,6 +99,9 @@
|
||||
@update:model-value="$emit(`update:authorEmail`, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- -> Whatever the caller has to show between the box and the buttons that act on it all: the
|
||||
annotations a comment is about to be posted with -->
|
||||
<slot />
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<div class="text-caption text-grey-6 hidden sm:block">
|
||||
{{ t('common.comments.markdownHint') }}
|
||||
@@ -109,7 +112,7 @@
|
||||
flat
|
||||
no-caps
|
||||
color="grey"
|
||||
:label="t(`common.actions.cancel`)"
|
||||
:label="cancelLabel || t(`common.actions.cancel`)"
|
||||
:disable="busy"
|
||||
@click="$emit(`cancel`)" />
|
||||
<w-btn
|
||||
@@ -178,6 +181,19 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/** What the button that abandons the box says, where Cancel is not the word for it. */
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* Whether the box may be submitted empty, which a comment may be only when its annotations say what
|
||||
* it has to say. The length limit still applies.
|
||||
*/
|
||||
allowEmpty: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -229,7 +245,8 @@ const charsLeft = computed(() => maxLength.value - props.modelValue.length)
|
||||
const showCounter = computed(() => charsLeft.value <= maxLength.value / 10)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.busy || props.modelValue.trim().length < 2 || charsLeft.value < 0) {
|
||||
const tooShort = !props.allowEmpty && props.modelValue.trim().length < 2
|
||||
if (props.busy || tooShort || charsLeft.value < 0) {
|
||||
return false
|
||||
}
|
||||
// -> A guest has two more fields to fill in, and the button says so by staying off until they are
|
||||
@@ -377,6 +394,9 @@ defineExpose({
|
||||
state.tab = 'write'
|
||||
await nextTick()
|
||||
inputEl.value?.focus(options)
|
||||
// -> At the end of whatever is already there, such as the mention a reply starts with
|
||||
const el = inputEl.value?.el
|
||||
el?.setSelectionRange(el.value.length, el.value.length)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -22,11 +22,24 @@
|
||||
:can-delete="mayModify(thread)"
|
||||
:busy="state.busy === thread.id"
|
||||
:editing="state.editingId === thread.id"
|
||||
:annotations-found="state.annotationsFound"
|
||||
:can-resolve="mayResolve(thread)"
|
||||
:can-edit-notes="mayModify(thread)"
|
||||
:editing-annotation-id="state.editingAnnotationId"
|
||||
:annotation-busy="state.busy"
|
||||
:can-delete-thread="canModerate"
|
||||
@reply="startReply"
|
||||
@edit="startEdit"
|
||||
@cancel-edit="state.editingId = null"
|
||||
@save="saveComment"
|
||||
@delete="confirmDelete" />
|
||||
@delete="confirmDelete"
|
||||
@delete-thread="confirmDeleteThread"
|
||||
@locate-annotation="emit(`locate-annotation`, $event)"
|
||||
@resolve-annotation="resolveAnnotation"
|
||||
@edit-annotation="startAnnotationEdit"
|
||||
@cancel-annotation-edit="state.editingAnnotationId = null"
|
||||
@save-annotation="saveAnnotation"
|
||||
@delete-annotation="confirmDeleteAnnotation" />
|
||||
<page-comment
|
||||
v-for="reply of thread.replies"
|
||||
:key="reply.id"
|
||||
@@ -120,6 +133,7 @@ import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { indexText, locateAnchor } from '@/helpers/annotations'
|
||||
import { scrollBehavior } from '@/helpers/motion'
|
||||
|
||||
import PageComment from '@/components/PageComment.vue'
|
||||
@@ -137,8 +151,23 @@ import PageCommentEditor from '@/components/PageCommentEditor.vue'
|
||||
* refreshed for this path. Not `userStore.can()`: that ORs the group-wide list in and answers "may do
|
||||
* this somewhere", where every button below has to mean "may do this here" — the endpoint behind each
|
||||
* one asks exactly that.
|
||||
*
|
||||
* **Annotations are looked for in the article as soon as the comments arrive**, and again whenever the
|
||||
* page is re-rendered, so that each one's button says whether its passage is still there before
|
||||
* anybody clicks it. The article is in the DOM the whole time this view is on screen -- hidden, not
|
||||
* removed (`pages/Index.vue`) -- so this is a walk over text that is already there.
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
/** The element the article was rendered into, which annotations are looked for in. */
|
||||
contentRoot: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['locate-annotation'])
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
@@ -181,7 +210,11 @@ const state = reactive({
|
||||
editingId: null,
|
||||
/** What a guest fills in. Kept here rather than per box, so it survives moving between them. */
|
||||
authorName: '',
|
||||
authorEmail: ''
|
||||
authorEmail: '',
|
||||
/** The annotation whose note is being edited, held here for the same reason as `editingId`. */
|
||||
editingAnnotationId: null,
|
||||
/** Whether each annotation's passage is still in the article, by id. See the note above. */
|
||||
annotationsFound: {}
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
@@ -190,6 +223,8 @@ const isGuest = computed(() => !userStore.authenticated)
|
||||
|
||||
const canWrite = computed(() => userStore.pagePermissions.includes('write:comments'))
|
||||
const canModerate = computed(() => userStore.pagePermissions.includes('manage:comments'))
|
||||
/** Whoever reviews the page may resolve and delete what was said about it, without moderating. */
|
||||
const canReview = computed(() => userStore.pagePermissions.includes('review:pages'))
|
||||
|
||||
/** Whether this page takes comments at all — the switch in its own properties dialog. */
|
||||
const isOpen = computed(() => pageStore.allowComments)
|
||||
@@ -201,6 +236,16 @@ const isOpen = computed(() => pageStore.allowComments)
|
||||
*/
|
||||
const canStartComment = computed(() => isOpen.value && (canWrite.value || isGuest.value))
|
||||
|
||||
/**
|
||||
* Whether there is anything for the New Annotation button to start. An account is required as well as
|
||||
* the right to comment: an annotation is resolved later by whoever posted it, and a guest has no
|
||||
* session to be recognized by -- the server refuses one too.
|
||||
*/
|
||||
const canStartAnnotation = computed(() => isOpen.value && canWrite.value && !isGuest.value)
|
||||
|
||||
/** How many annotations can still be found in the article -- what View All Annotations would show. */
|
||||
const annotationCount = computed(() => Object.values(state.annotationsFound).filter(Boolean).length)
|
||||
|
||||
const bannerClass = computed(() =>
|
||||
dark.isActive ? 'bg-grey-9 text-grey-4' : 'bg-grey-2 text-grey-8'
|
||||
)
|
||||
@@ -233,6 +278,13 @@ const threads = computed(() => {
|
||||
|
||||
// WATCHERS
|
||||
|
||||
// -> After the DOM has caught up, since what is being searched is the article as drawn
|
||||
watch(
|
||||
() => [state.comments, pageStore.render, props.contentRoot],
|
||||
() => findAnnotations(),
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
// -> The talk of the page in front of the reader, so moving to another one reloads rather than
|
||||
// leaving the previous discussion under the new article
|
||||
watch(
|
||||
@@ -251,6 +303,34 @@ function mayModify(comment) {
|
||||
return canWrite.value && Boolean(comment.authorId) && comment.authorId === userStore.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this reader may resolve, reopen and delete the annotations of a comment: its author, while
|
||||
* they may still comment here, and whoever reviews or moderates the page.
|
||||
*/
|
||||
function mayResolve(comment) {
|
||||
if (canModerate.value || canReview.value) {
|
||||
return true
|
||||
}
|
||||
return canWrite.value && Boolean(comment.authorId) && comment.authorId === userStore.id
|
||||
}
|
||||
|
||||
/** Look for every annotation's passage in the article. See the note at the top. */
|
||||
function findAnnotations() {
|
||||
const annotations = state.comments.flatMap((comment) => comment.annotations ?? [])
|
||||
if (!props.contentRoot || annotations.length < 1) {
|
||||
state.annotationsFound = {}
|
||||
return
|
||||
}
|
||||
// -> One walk of the article for all of them
|
||||
const index = indexText(props.contentRoot)
|
||||
state.annotationsFound = Object.fromEntries(
|
||||
annotations.map((annotation) => [
|
||||
annotation.id,
|
||||
Boolean(locateAnchor(index, annotation.anchor))
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!pageStore.id || !siteStore.comments.isBuiltIn) {
|
||||
return
|
||||
@@ -288,7 +368,13 @@ function startReply(comment) {
|
||||
// clicked -- but it is addressed to whoever was actually being answered
|
||||
state.replyTo = comment.parentId ?? comment.id
|
||||
state.replyToName = comment.authorName
|
||||
state.replyDraft = ''
|
||||
/*
|
||||
Started with a mention of whoever is being answered, where they have a handle to be mentioned by
|
||||
-- which is also what tells them they were answered. Not of oneself: answering a thread from your
|
||||
own comment in it is adding to it, and a mention of you by you notifies nobody.
|
||||
*/
|
||||
const mention = comment.authorHandle && comment.authorId !== userStore.id
|
||||
state.replyDraft = mention ? `@${comment.authorHandle} ` : ''
|
||||
nextTick(() => {
|
||||
// -> A ref inside a `v-for` collects into an array, and only one reply box is ever rendered
|
||||
const box = Array.isArray(replyEditor.value) ? replyEditor.value[0] : replyEditor.value
|
||||
@@ -358,10 +444,68 @@ async function saveComment({ id, content }) {
|
||||
state.busy = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment, asking first -- and asking what to do with its replies where that is the reader's
|
||||
* to decide.
|
||||
*
|
||||
* Replies stay by default: they are other people's words, so the comment is replaced by a note that
|
||||
* it was deleted and the thread goes on under it. Taking the whole thread is a moderator's call
|
||||
* (`manage:comments` here), so only a moderator is offered it; an author deleting their own comment
|
||||
* is told the replies will stay instead.
|
||||
*/
|
||||
function confirmDelete(comment) {
|
||||
const hasReplies = state.comments.some((c) => c.parentId === comment.id)
|
||||
const offerThread = hasReplies && canModerate.value
|
||||
let message = t('common.comments.deleteWarn')
|
||||
if (offerThread) {
|
||||
message = t('common.comments.deleteWarnModerator')
|
||||
} else if (hasReplies) {
|
||||
message = t('common.comments.deleteWarnKeepReplies')
|
||||
}
|
||||
confirm({
|
||||
title: t('common.comments.deleteConfirmTitle'),
|
||||
message: t('common.comments.deleteWarn'),
|
||||
message,
|
||||
...(offerThread && {
|
||||
options: {
|
||||
model: 'comment',
|
||||
type: 'radio',
|
||||
items: [
|
||||
{ label: t('common.comments.deleteCommentOnly'), value: 'comment' },
|
||||
{ label: t('common.comments.deleteWithReplies'), value: 'thread' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
persistent: true,
|
||||
cancel: true,
|
||||
color: 'negative',
|
||||
okLabel: t('common.actions.delete')
|
||||
}).onOk(async (choice) => {
|
||||
state.busy = comment.id
|
||||
try {
|
||||
await API_CLIENT.delete(`sites/${siteStore.id}/comments/${comment.id}`, {
|
||||
...(choice === 'thread' && { searchParams: { withReplies: true } })
|
||||
}).json()
|
||||
notify({ type: 'positive', message: t('common.comments.deleteSuccess') })
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.comments.deleteFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.busy = ''
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete what is left of a thread whose opening comment was already deleted: the replies under its
|
||||
* placeholder, and the placeholder with them. Only ever offered to a moderator.
|
||||
*/
|
||||
function confirmDeleteThread(comment) {
|
||||
confirm({
|
||||
title: t('common.comments.deleteThreadTitle'),
|
||||
message: t('common.comments.deleteThreadWarn'),
|
||||
persistent: true,
|
||||
cancel: true,
|
||||
color: 'negative',
|
||||
@@ -369,8 +513,10 @@ function confirmDelete(comment) {
|
||||
}).onOk(async () => {
|
||||
state.busy = comment.id
|
||||
try {
|
||||
await API_CLIENT.delete(`sites/${siteStore.id}/comments/${comment.id}`).json()
|
||||
notify({ type: 'positive', message: t('common.comments.deleteSuccess') })
|
||||
await API_CLIENT.delete(`sites/${siteStore.id}/comments/${comment.id}`, {
|
||||
searchParams: { withReplies: true }
|
||||
}).json()
|
||||
notify({ type: 'positive', message: t('common.comments.deleteThreadSuccess') })
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
@@ -383,6 +529,88 @@ function confirmDelete(comment) {
|
||||
})
|
||||
}
|
||||
|
||||
function startAnnotationEdit(annotation) {
|
||||
state.editingAnnotationId = annotation.id
|
||||
}
|
||||
|
||||
async function saveAnnotation({ id, note }) {
|
||||
state.busy = id
|
||||
try {
|
||||
await API_CLIENT.put(`sites/${siteStore.id}/annotations/${id}`, { json: { note } }).json()
|
||||
// -> Only once saved, as for a comment: a failure leaves the box open with the text in it
|
||||
state.editingAnnotationId = null
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.comments.annotationUpdateFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.busy = ''
|
||||
}
|
||||
|
||||
async function resolveAnnotation({ annotation, resolved }) {
|
||||
state.busy = annotation.id
|
||||
try {
|
||||
await API_CLIENT.put(`sites/${siteStore.id}/annotations/${annotation.id}/resolved`, {
|
||||
json: { resolved }
|
||||
}).json()
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.comments.annotationUpdateFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.busy = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether deleting this annotation takes its comment with it: the last annotation of a comment with
|
||||
* no text of its own, which the server then deletes too (`removeAnnotation` in the model). Asked here
|
||||
* only to say so before it happens -- the server decides.
|
||||
*/
|
||||
function emptiesComment(annotation) {
|
||||
const comment = state.comments.find((c) => c.id === annotation.commentId)
|
||||
return Boolean(comment) && comment.annotations.length === 1 && comment.content.trim().length < 2
|
||||
}
|
||||
|
||||
function confirmDeleteAnnotation(annotation) {
|
||||
confirm({
|
||||
title: t('common.comments.annotationDeleteTitle'),
|
||||
message: emptiesComment(annotation)
|
||||
? t('common.comments.annotationDeleteLastWarn')
|
||||
: t('common.comments.annotationDeleteWarn'),
|
||||
persistent: true,
|
||||
cancel: true,
|
||||
color: 'negative',
|
||||
okLabel: t('common.actions.delete')
|
||||
}).onOk(async () => {
|
||||
state.busy = annotation.id
|
||||
try {
|
||||
const resp = await API_CLIENT.delete(
|
||||
`sites/${siteStore.id}/annotations/${annotation.id}`
|
||||
).json()
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: resp?.commentDeleted
|
||||
? t('common.comments.deleteSuccess')
|
||||
: t('common.comments.annotationDeleteSuccess')
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('common.comments.annotationDeleteFailed'),
|
||||
caption: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.busy = ''
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the box at the bottom into view and puts the caret in it.
|
||||
*
|
||||
@@ -403,9 +631,12 @@ onMounted(() => {
|
||||
|
||||
// EXPOSED
|
||||
|
||||
/* -> For the column beside the view (`pages/Index.vue`), which offers New Comment from there */
|
||||
/* -> For the column beside the view (`pages/Index.vue`), which offers New Comment and New
|
||||
Annotation from there */
|
||||
defineExpose({
|
||||
canStartComment,
|
||||
canStartAnnotation,
|
||||
annotationCount,
|
||||
startNewComment
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<span v-if="label !== null">{{ label }}</span>
|
||||
<slot />
|
||||
</span>
|
||||
<!-- -> A direct child of the button rather than of the content, so a full-width button can push it
|
||||
to the far end with `margin-inline-start: auto` -->
|
||||
<slot name="append" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -32,7 +35,8 @@ import WSpinner from './WSpinner.vue'
|
||||
* Button.
|
||||
*
|
||||
* Variants mirror the four the app uses: raised (default), `flat`, `unelevated` and `push`.
|
||||
* Content comes from the `label` prop, the default slot, or both.
|
||||
* Content comes from the `label` prop, the default slot, or both. The `append` slot is for something
|
||||
* that sits apart from the content, at the far end of a button wider than it -- a count, say.
|
||||
*/
|
||||
const props = defineProps({
|
||||
label: {
|
||||
|
||||
@@ -416,22 +416,14 @@ $toc-overlay-max: 749.98px;
|
||||
}
|
||||
|
||||
/*
|
||||
The Talk view's column (`pages/Index.vue`): a stack of full-width buttons, starting level with the
|
||||
first comment rather than with the top of the column -- the column runs up beside the Article / Talk
|
||||
strip, and what these act on is the discussion under it. That is the strip's 44px (`PageViewTabs.vue`,
|
||||
which in this view gives up its 1px gap) and the 1rem the view's body is padded by.
|
||||
|
||||
Only while it is a column. As a panel over the view, there is nothing beside it to line up with.
|
||||
The Talk view's column (`pages/Index.vue`): two headed groups of full-width buttons, comments and
|
||||
then annotations, laid out from the top of the column like the article's own column is.
|
||||
*/
|
||||
.page-talk-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: calc(44px + 1rem) 1rem 1rem;
|
||||
|
||||
@media (max-width: $toc-overlay-max) {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
padding: 1rem;
|
||||
|
||||
.w-btn {
|
||||
justify-content: flex-start;
|
||||
@@ -439,6 +431,20 @@ $toc-overlay-max: 749.98px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-talk-actions-count {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.page-talk-actions-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* -> Edge to edge, as the separators between the article column's own sections are */
|
||||
.page-talk-actions-sep {
|
||||
margin: 0.5rem -1rem;
|
||||
}
|
||||
|
||||
.page-sidebar {
|
||||
flex: 0 0 300px;
|
||||
|
||||
@@ -574,3 +580,25 @@ $toc-overlay-max: 749.98px;
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
An annotated passage as it is quoted back: in the annotator's review (`PageAnnotator.vue`) and under
|
||||
its comment in the Talk view (`PageComment.vue`). Here rather than in either, since both draw it. The
|
||||
rule down the side is the colour the passage was painted in while it was picked
|
||||
(`::highlight(wiki-annotation-pending)` in `_page-contents.scss`), so the two read as the same thing.
|
||||
*/
|
||||
.page-annotation-quote {
|
||||
display: -webkit-box;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 0 1px 10px;
|
||||
overflow: hidden;
|
||||
border-left: 3px solid var(--q-warning);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
opacity: 0.8;
|
||||
word-break: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
@@ -2438,3 +2438,48 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Annotated passages, painted through the CSS Custom Highlight API rather than wrapped in elements:
|
||||
the names are `HIGHLIGHTS` in `helpers/annotations.js`, and the two have to be kept in step. Nothing
|
||||
is ever inserted into the article, which is what keeps the text one annotation is matched against
|
||||
the text the next one was picked from.
|
||||
|
||||
Top level, because a highlight pseudo-element is matched on whatever element holds the text; the
|
||||
custom properties still resolve from there, so the article's own `--content-mark` serves both themes.
|
||||
A highlight can only take colours and a text decoration -- no padding, no shadow, no radius.
|
||||
*/
|
||||
::highlight(wiki-annotation-pending) {
|
||||
background-color: color-mix(in srgb, var(--q-warning) 32%, transparent);
|
||||
}
|
||||
|
||||
::highlight(wiki-annotation-draft) {
|
||||
background-color: color-mix(in srgb, var(--q-warning) 45%, transparent);
|
||||
text-decoration: underline 2px var(--q-warning);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* -> View All Annotations: every open one, quieter than one being picked, and the resolved ones as a
|
||||
line under the text alone -- still there to be found, and drawn as done */
|
||||
::highlight(wiki-annotation-shown) {
|
||||
background-color: color-mix(in srgb, var(--q-warning) 22%, transparent);
|
||||
text-decoration: underline 1px var(--q-warning);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
::highlight(wiki-annotation-resolved) {
|
||||
text-decoration: underline dotted 1px var(--q-positive);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* -> Over a passage that opens its note, which a highlight cannot say itself -- see
|
||||
`PageAnnotationsLayer.vue` for why this is an attribute */
|
||||
.page-contents[data-annotation-hover] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::highlight(wiki-annotation-focus) {
|
||||
background-color: var(--content-mark);
|
||||
text-decoration: underline 2px var(--q-primary);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ function reveal(el) {
|
||||
* The article has its own scroller rather than the window — the shell stays put and the column moves
|
||||
* — so the position of the heading has to be read against that box, not the viewport.
|
||||
*/
|
||||
function scrollerOf(el) {
|
||||
export function scrollerOf(el) {
|
||||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node)
|
||||
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight + 1) {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Finding a passage of an article again, after the page it was picked from has been edited.
|
||||
*
|
||||
* An annotation is stored as a DESCRIPTION of the passage rather than a pointer into the page: the
|
||||
* text itself, a little of what was either side of it, and the heading it sat under (`CommentAnchor`
|
||||
* in `backend/models/comments.ts`). A pointer -- an element path, an offset into the render -- is
|
||||
* right about exactly one version of the page; a description is found again in any version that
|
||||
* still says the same thing, which is what lets a section added above it leave it linkable.
|
||||
*
|
||||
* **Text is the article as drawn, with whitespace folded.** Every run of whitespace counts as one
|
||||
* space, so a re-render that wraps a paragraph differently, or a block that sits on its own line in
|
||||
* one version and inline in the next, changes nothing about where a passage is. The controls the
|
||||
* app adds to the render (a heading's pilcrow, a code block's copy button) are not text, and neither
|
||||
* is anything inside an `<svg>` or a `<script>` -- see `SKIPPED`.
|
||||
*
|
||||
* **Matching is tolerant.** A passage is found as long as its exact text is still in the page and
|
||||
* can be told apart from any other place the same text occurs. Where it occurs once, that is the
|
||||
* place, whatever has happened around it. Where it occurs more than once, the text either side and
|
||||
* the heading decide, and the distance into the section breaks a tie; a passage with no evidence for
|
||||
* any of its occurrences is ambiguous and is reported as lost, rather than shown somewhere it may
|
||||
* never have been.
|
||||
*/
|
||||
|
||||
import { REVEAL_EVENT, scrollerOf } from './anchors'
|
||||
import { scrollBehavior } from './motion'
|
||||
|
||||
/** How much text either side of a passage is kept to tell its occurrences apart. */
|
||||
const CONTEXT_LENGTH = 32
|
||||
|
||||
/**
|
||||
* The longest passage that may be annotated. The server holds the same number
|
||||
* (`ANNOTATION_QUOTE_MAX_LENGTH`) and refuses anything longer.
|
||||
*/
|
||||
export const QUOTE_MAX_LENGTH = 1000
|
||||
|
||||
/** How many occurrences of a passage are weighed before giving up on telling them apart. */
|
||||
const MAX_CANDIDATES = 200
|
||||
|
||||
/** What a shared heading is worth against characters of matching context. */
|
||||
const HEADING_WEIGHT = 16
|
||||
|
||||
/** Elements whose contents are not the article's text. */
|
||||
const SKIPPED = new Set(['button', 'script', 'style', 'template', 'textarea', 'noscript', 'svg'])
|
||||
|
||||
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
|
||||
|
||||
/**
|
||||
* The names the passages are painted under, through the CSS Custom Highlight API -- which colours a
|
||||
* range without putting a single element into the article, so nothing an annotation does can change
|
||||
* the text the next one is matched against. Styled in `_page-contents.scss`.
|
||||
*/
|
||||
export const HIGHLIGHTS = {
|
||||
/** Picked and noted, waiting to be posted. */
|
||||
pending: 'wiki-annotation-pending',
|
||||
/** Picked, with its note still being written. */
|
||||
draft: 'wiki-annotation-draft',
|
||||
/** The one whose note is open, with View All Annotations on. */
|
||||
focus: 'wiki-annotation-focus',
|
||||
/** Every open annotation on the page, with View All Annotations on. */
|
||||
shown: 'wiki-annotation-shown',
|
||||
/** Every resolved one, likewise -- still there to be found, and drawn as done. */
|
||||
resolved: 'wiki-annotation-resolved'
|
||||
}
|
||||
|
||||
/** The width of a card drawn beside a passage, which decides whether it fits beside it at all. */
|
||||
export const CARD_WIDTH = 300
|
||||
|
||||
/** Clear space between such a card and its passage, and between the card and the window's edge. */
|
||||
const CARD_GAP = 12
|
||||
const CARD_MARGIN = 16
|
||||
|
||||
const canHighlight = typeof CSS !== 'undefined' && 'highlights' in CSS && 'Highlight' in window
|
||||
|
||||
/**
|
||||
* The text of an article, folded, with a way back from every character of it to the DOM.
|
||||
*
|
||||
* @param {HTMLElement} root The element the render was written into.
|
||||
* @returns {{ text: string, nodes: Text[], nodeAt: number[], offsetAt: number[], headings: { id: string, start: number }[] }}
|
||||
* `nodeAt[i]` and `offsetAt[i]` are where character `i` of `text` is; `headings` are in document
|
||||
* order, each with where its section starts in `text`.
|
||||
*/
|
||||
export function indexText(root) {
|
||||
const nodes = []
|
||||
const chars = []
|
||||
const nodeAt = []
|
||||
const offsetAt = []
|
||||
const headings = []
|
||||
// -> True at the start, so leading whitespace is dropped rather than folded into a space
|
||||
let lastWasSpace = true
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
if (SKIPPED.has(node.localName)) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
// -> Elements are only of interest as section starts; their children are visited either way
|
||||
return HEADINGS.has(node.localName) && node.id
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_SKIP
|
||||
}
|
||||
})
|
||||
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (node.nodeType !== Node.TEXT_NODE) {
|
||||
headings.push({ id: node.id, start: chars.length })
|
||||
continue
|
||||
}
|
||||
const data = node.data
|
||||
const nodeIdx = nodes.length
|
||||
nodes.push(node)
|
||||
for (let offset = 0; offset < data.length; offset++) {
|
||||
const isSpace = /\s/.test(data[offset])
|
||||
if (isSpace && lastWasSpace) {
|
||||
continue
|
||||
}
|
||||
chars.push(isSpace ? ' ' : data[offset])
|
||||
nodeAt.push(nodeIdx)
|
||||
offsetAt.push(offset)
|
||||
lastWasSpace = isSpace
|
||||
}
|
||||
}
|
||||
|
||||
return { text: chars.join(''), nodes, nodeAt, offsetAt, headings }
|
||||
}
|
||||
|
||||
/**
|
||||
* The first character of the index at or after a point in the DOM.
|
||||
*
|
||||
* A point may be in a text node the index holds, which is the usual case and a binary search; or
|
||||
* between the children of an element, or inside something the index skips, in which case it is the
|
||||
* first text node that starts after the point -- also found by halving, since the nodes are in
|
||||
* document order.
|
||||
*/
|
||||
function pointToIndex(index, container, offset) {
|
||||
let nodeIdx = index.nodes.indexOf(container)
|
||||
let nodeOffset = offset
|
||||
if (nodeIdx < 0) {
|
||||
const point = document.createRange()
|
||||
point.setStart(container, offset)
|
||||
let lo = 0
|
||||
let hi = index.nodes.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (point.comparePoint(index.nodes[mid], 0) >= 0) {
|
||||
hi = mid
|
||||
} else {
|
||||
lo = mid + 1
|
||||
}
|
||||
}
|
||||
nodeIdx = lo
|
||||
nodeOffset = 0
|
||||
}
|
||||
// -> The characters are in (node, offset) order, so this is a search on the pair
|
||||
let lo = 0
|
||||
let hi = index.text.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
const before =
|
||||
index.nodeAt[mid] < nodeIdx ||
|
||||
(index.nodeAt[mid] === nodeIdx && index.offsetAt[mid] < nodeOffset)
|
||||
if (before) {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
/** The DOM range covering characters `start` up to `end` of the index. */
|
||||
function rangeFor(index, start, end) {
|
||||
const range = document.createRange()
|
||||
range.setStart(index.nodes[index.nodeAt[start]], index.offsetAt[start])
|
||||
range.setEnd(index.nodes[index.nodeAt[end - 1]], index.offsetAt[end - 1] + 1)
|
||||
return range
|
||||
}
|
||||
|
||||
/** The section a character is in: the last heading at or before it, or null above the first. */
|
||||
function headingAt(index, at) {
|
||||
let found = null
|
||||
for (const heading of index.headings) {
|
||||
if (heading.start > at) {
|
||||
break
|
||||
}
|
||||
found = heading
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe a selection, for storing.
|
||||
*
|
||||
* @param {ReturnType<typeof indexText>} index
|
||||
* @param {Range} range What the reader selected. Must be inside the indexed root.
|
||||
* @returns {{ anchor: object, range: Range } | null} The anchor to store, and the range trimmed to
|
||||
* exactly the text it describes -- or null when the selection holds no text at all.
|
||||
*/
|
||||
export function describeRange(index, range) {
|
||||
let start = pointToIndex(index, range.startContainer, range.startOffset)
|
||||
let end = pointToIndex(index, range.endContainer, range.endOffset)
|
||||
// -> A drag usually catches a space at one end or the other, which is not part of the passage
|
||||
while (start < end && index.text[start] === ' ') {
|
||||
start++
|
||||
}
|
||||
while (end > start && index.text[end - 1] === ' ') {
|
||||
end--
|
||||
}
|
||||
if (end <= start) {
|
||||
return null
|
||||
}
|
||||
const heading = headingAt(index, start)
|
||||
return {
|
||||
anchor: {
|
||||
exact: index.text.slice(start, end),
|
||||
prefix: index.text.slice(Math.max(0, start - CONTEXT_LENGTH), start),
|
||||
suffix: index.text.slice(end, end + CONTEXT_LENGTH),
|
||||
heading: heading?.id ?? null,
|
||||
offset: start - (heading?.start ?? 0)
|
||||
},
|
||||
range: rangeFor(index, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
/** How many characters at the end of `a` match the end of `b`. */
|
||||
function sharedEnd(a, b) {
|
||||
let n = 0
|
||||
while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** How many characters at the start of `a` match the start of `b`. */
|
||||
function sharedStart(a, b) {
|
||||
let n = 0
|
||||
while (n < a.length && n < b.length && a[n] === b[n]) {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a stored passage in the article as it stands now.
|
||||
*
|
||||
* @param {ReturnType<typeof indexText>} index
|
||||
* @param {object} anchor As `describeRange` made it.
|
||||
* @returns {Range | null} Where it is, or null when it is gone or cannot be told apart from another
|
||||
* place the same text occurs. See the note at the top of this file.
|
||||
*/
|
||||
export function locateAnchor(index, anchor) {
|
||||
const { text } = index
|
||||
const exact = anchor?.exact ?? ''
|
||||
if (!exact) {
|
||||
return null
|
||||
}
|
||||
const hits = []
|
||||
for (let at = text.indexOf(exact); at >= 0 && hits.length < MAX_CANDIDATES;) {
|
||||
hits.push(at)
|
||||
at = text.indexOf(exact, at + 1)
|
||||
}
|
||||
if (hits.length < 1) {
|
||||
return null
|
||||
}
|
||||
if (hits.length === 1) {
|
||||
return rangeFor(index, hits[0], hits[0] + exact.length)
|
||||
}
|
||||
|
||||
const prefix = anchor.prefix ?? ''
|
||||
const suffix = anchor.suffix ?? ''
|
||||
const scored = hits.map((at) => {
|
||||
const end = at + exact.length
|
||||
const context =
|
||||
sharedEnd(text.slice(Math.max(0, at - prefix.length), at), prefix) +
|
||||
sharedStart(text.slice(end, end + suffix.length), suffix)
|
||||
const heading = headingAt(index, at)
|
||||
const sameHeading = (heading?.id ?? null) === (anchor.heading ?? null)
|
||||
return {
|
||||
at,
|
||||
context,
|
||||
sameHeading,
|
||||
score: context + (sameHeading ? HEADING_WEIGHT : 0),
|
||||
// -> Only meaningful within the section it was picked in
|
||||
distance: sameHeading
|
||||
? Math.abs(at - (heading?.start ?? 0) - (anchor.offset ?? 0))
|
||||
: Number.POSITIVE_INFINITY
|
||||
}
|
||||
})
|
||||
scored.sort((a, b) => b.score - a.score || a.distance - b.distance)
|
||||
const [best, runnerUp] = scored
|
||||
if (best.context < 1 && !best.sameHeading) {
|
||||
return null
|
||||
}
|
||||
if (runnerUp && runnerUp.score === best.score && runnerUp.distance === best.distance) {
|
||||
return null
|
||||
}
|
||||
return rangeFor(index, best.at, best.at + exact.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint ranges under one of the `HIGHLIGHTS` names, replacing whatever was painted under it.
|
||||
*
|
||||
* @returns {boolean} Whether the browser can paint them at all. One that cannot still has every
|
||||
* other part of annotating; it only loses the colour.
|
||||
*/
|
||||
export function paintHighlight(name, ranges) {
|
||||
if (!canHighlight) {
|
||||
return false
|
||||
}
|
||||
if (ranges.length < 1) {
|
||||
CSS.highlights.delete(name)
|
||||
} else {
|
||||
CSS.highlights.set(name, new Highlight(...ranges))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Take every annotation highlight off the page. */
|
||||
export function clearHighlights() {
|
||||
for (const name of Object.values(HIGHLIGHTS)) {
|
||||
paintHighlight(name, [])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a passage into view, a third of the way down the article's scroller.
|
||||
*
|
||||
* Asked of whatever is above it to reveal it first, the way a heading in a closed tab is (see
|
||||
* `helpers/anchors.js`) -- and measured a frame later, since a block draws the panel it opened on its
|
||||
* own update cycle.
|
||||
*/
|
||||
export function scrollRangeIntoView(range) {
|
||||
const el =
|
||||
range.startContainer.nodeType === Node.ELEMENT_NODE
|
||||
? range.startContainer
|
||||
: range.startContainer.parentElement
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
el.dispatchEvent(new CustomEvent(REVEAL_EVENT, { bubbles: true, composed: true }))
|
||||
requestAnimationFrame(() => {
|
||||
const scroller = scrollerOf(el)
|
||||
const rect = range.getBoundingClientRect()
|
||||
const box = scroller.getBoundingClientRect()
|
||||
const top = scroller === document.scrollingElement ? 0 : box.top
|
||||
scroller.scrollBy({
|
||||
top: rect.top - top - scroller.clientHeight / 3,
|
||||
behavior: scrollBehavior()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to draw a card about a passage: to the right of its last line, or under it where the window
|
||||
* has no room on the right -- a passage ending near the far edge of the column, or any passage on a
|
||||
* phone. Kept inside the window, and above `floor` (a panel along the bottom, say).
|
||||
*
|
||||
* @param {Range} range The passage.
|
||||
* @param {{ height?: number, floor?: number }} [opts] The card's height as drawn, and the lowest its
|
||||
* bottom edge may sit.
|
||||
* @returns {{ left: string, top: string, width: string }} A style for a `position: fixed` card.
|
||||
*/
|
||||
export function placeBeside(range, { height = 150, floor = window.innerHeight } = {}) {
|
||||
const rects = range.getClientRects()
|
||||
const last = rects[rects.length - 1] ?? range.getBoundingClientRect()
|
||||
const width = Math.min(CARD_WIDTH, window.innerWidth - CARD_MARGIN * 2)
|
||||
|
||||
let left = last.right + CARD_GAP
|
||||
let top = last.top - 8
|
||||
if (left + width > window.innerWidth - CARD_MARGIN) {
|
||||
left = Math.min(Math.max(last.left, CARD_MARGIN), window.innerWidth - width - CARD_MARGIN)
|
||||
top = last.bottom + CARD_GAP
|
||||
}
|
||||
top = Math.max(CARD_MARGIN, Math.min(top, floor - CARD_MARGIN - height))
|
||||
return { left: `${left}px`, top: `${top}px`, width: `${width}px` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a point on screen falls on a passage.
|
||||
*
|
||||
* Asked of the passage's own boxes, one per line it runs over, because a highlight is not an element:
|
||||
* nothing about it can be clicked or hovered, and the browser has no way to say which one a pointer
|
||||
* is over.
|
||||
*/
|
||||
export function rangeContainsPoint(range, x, y) {
|
||||
for (const rect of range.getClientRects()) {
|
||||
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+205
-10
@@ -161,7 +161,11 @@
|
||||
is left under the strip is exactly what it gets -->
|
||||
<w-scroll-area
|
||||
class="page-container-scrl page-article-col flex-1 min-h-0"
|
||||
:class="{ 'is-talk': activeView === `talk` }"
|
||||
:class="{
|
||||
'is-talk': activeView === `talk`,
|
||||
'is-annotating': isAnnotating,
|
||||
'is-picking': isAnnotating && annotator?.isPicking
|
||||
}"
|
||||
ref="pageScroller"
|
||||
v-else>
|
||||
<!-- -> Half the padding on a phone, where 16px a side is 8% of the window spent on margin;
|
||||
@@ -199,7 +203,35 @@
|
||||
`v-show` rather than `v-if` on the article below, so that leaving the discussion and
|
||||
coming back does not re-run the page's own scripts or lose where the reader was in it.
|
||||
-->
|
||||
<page-talk v-if="activeView === `talk`" ref="talkView" />
|
||||
<page-talk
|
||||
v-if="activeView === `talk`"
|
||||
ref="talkView"
|
||||
:content-root="pageContents"
|
||||
@locate-annotation="locateAnnotation" />
|
||||
<!--
|
||||
Picking passages of the article to post as a comment, started from the Talk view's column.
|
||||
Over the article and only while it is the view on screen: the panel and the card it draws
|
||||
are teleported to the body, so where this sits in the tree is only what owns it.
|
||||
-->
|
||||
<page-annotator
|
||||
v-if="isAnnotating"
|
||||
ref="annotator"
|
||||
:root="pageContents"
|
||||
@close="stopAnnotating"
|
||||
@posted="onAnnotationsPosted" />
|
||||
<!--
|
||||
Every annotation on the page at once, from the Talk view's column. Mounted for as long as
|
||||
it is switched on, whichever view is showing -- it is turned off from the Talk view, not by
|
||||
leaving the article -- and drawing only over the article.
|
||||
-->
|
||||
<page-annotations-layer
|
||||
v-if="state.showAnnotations"
|
||||
:root="pageContents"
|
||||
:active="activeView === `article`"
|
||||
:interactive="!isAnnotating"
|
||||
:reveal-id="state.revealAnnotationId"
|
||||
@revealed="state.revealAnnotationId = null"
|
||||
@view-comment="viewComment" />
|
||||
<page-links v-if="activeView === `links`" />
|
||||
<!--
|
||||
A blog's front page, which has no article to draw: its posts are what stands in place of
|
||||
@@ -353,10 +385,15 @@
|
||||
<page-blog-sidebar v-if="isBlog" />
|
||||
<!--
|
||||
The Talk view's column: what can be started from it, in place of the contents, tags, rating
|
||||
and last editor, which are all about the article and are not what is on screen. Down at the
|
||||
height of the first comment rather than up beside the tab strip -- see `.page-talk-actions`.
|
||||
and last editor, which are all about the article and are not what is on screen.
|
||||
-->
|
||||
<div v-else-if="activeView === `talk`" class="page-talk-actions">
|
||||
<!-- -> Headed the way the article's column is ("Contents"), so the two read as one column
|
||||
that changed what it holds -->
|
||||
<div class="page-talk-actions-title">
|
||||
<w-icon class="me-2" name="la:comments" color="grey" />
|
||||
<div class="text-caption text-grey-7">{{ t('common.comments.commentsTitle') }}</div>
|
||||
</div>
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
@@ -366,7 +403,11 @@
|
||||
:label="t('common.comments.newComment')"
|
||||
:disable="!talkView?.canStartComment"
|
||||
@click="startNewComment" />
|
||||
<!-- -> Not built yet: shown so the column says what it will hold, and held off until then -->
|
||||
<w-separator class="page-talk-actions-sep" />
|
||||
<div class="page-talk-actions-title">
|
||||
<w-icon class="me-2" name="la:sticky-note" color="grey" />
|
||||
<div class="text-caption text-grey-7">{{ t('common.comments.annotationsTitle') }}</div>
|
||||
</div>
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
@@ -374,7 +415,33 @@
|
||||
color="primary"
|
||||
icon="la:highlighter"
|
||||
:label="t('common.comments.newAnnotation')"
|
||||
disable />
|
||||
:disable="!talkView?.canStartAnnotation"
|
||||
@click="startAnnotating" />
|
||||
<!-- -> Only something to show where there is a passage still in the page to show; turning it
|
||||
off is always on offer while it is on -->
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
no-caps
|
||||
:color="dark.isActive ? `indigo-4` : `indigo`"
|
||||
:icon="state.showAnnotations ? `la:eye-slash` : `la:eye`"
|
||||
:label="
|
||||
state.showAnnotations
|
||||
? t('common.comments.hideAllAnnotations')
|
||||
: t('common.comments.viewAllAnnotations')
|
||||
"
|
||||
:disable="!state.showAnnotations && !talkView?.annotationCount"
|
||||
@click="toggleAllAnnotations">
|
||||
<!-- -> How many there are to show: the passages still in the article, open and resolved -->
|
||||
<template #append>
|
||||
<w-badge
|
||||
v-if="talkView?.annotationCount > 0"
|
||||
class="page-talk-actions-count"
|
||||
rounded
|
||||
:color="dark.isActive ? `indigo-4` : `indigo`"
|
||||
:label="talkView.annotationCount" />
|
||||
</template>
|
||||
</w-btn>
|
||||
</div>
|
||||
<template v-else>
|
||||
<template v-if="showToc">
|
||||
@@ -549,7 +616,7 @@ import {
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
@@ -561,6 +628,7 @@ import { notify } from '@/composables/notify'
|
||||
import { withViewTransition } from '@/composables/viewTransition'
|
||||
import { loading } from '@/composables/loading'
|
||||
import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors'
|
||||
import { clearHighlights, indexText, locateAnchor } from '@/helpers/annotations'
|
||||
import { isPagePath, splitLocalePath } from '@/helpers/pagePaths'
|
||||
import {
|
||||
enhanceRenderedContent,
|
||||
@@ -600,6 +668,11 @@ const PageTalk = defineAsyncComponent({
|
||||
loadingComponent: LoadingGeneric
|
||||
})
|
||||
const PageCommentsEmbed = defineAsyncComponent(() => import('@/components/PageCommentsEmbed.vue'))
|
||||
/* -> Likewise, and only once somebody actually starts annotating, or asks to see the annotations */
|
||||
const PageAnnotator = defineAsyncComponent(() => import('@/components/PageAnnotator.vue'))
|
||||
const PageAnnotationsLayer = defineAsyncComponent(
|
||||
() => import('@/components/PageAnnotationsLayer.vue')
|
||||
)
|
||||
/*
|
||||
Likewise on demand: what links to a page is a list nobody is looking at until they ask for it, and
|
||||
it is a request as well as a chunk -- see the note on the tab's missing badge in `PageViewTabs.vue`.
|
||||
@@ -724,13 +797,27 @@ const state = reactive({
|
||||
* has not asked to land on the discussion of the next page they open -- unless the link they
|
||||
* followed said so, which is what `#talk` is (see `viewFromHash`).
|
||||
*/
|
||||
view: viewFromHash()
|
||||
view: viewFromHash(),
|
||||
/**
|
||||
* Whether passages of the article are being picked for a comment -- see `PageAnnotator.vue`. Never
|
||||
* carried from one page to the next: the passages are ranges into this page's render.
|
||||
*/
|
||||
annotating: false,
|
||||
/**
|
||||
* Whether every annotation is painted over the article -- View All Annotations. Stays on across the
|
||||
* two views until it is turned off, and off again on another page, whose annotations these are not.
|
||||
*/
|
||||
showAnnotations: false,
|
||||
/** An annotation to scroll to with its note open, once the layer has found it. */
|
||||
revealAnnotationId: null
|
||||
})
|
||||
const pageContents = ref(null)
|
||||
/** The article column, which is what scrolls -- see `scrollPageToTop`. */
|
||||
const pageScroller = ref(null)
|
||||
/** The Talk view while it is on screen, for the New Comment button in the column beside it. */
|
||||
const talkView = ref(null)
|
||||
/** The annotator while it is open, which knows whether leaving would throw anything away. */
|
||||
const annotator = ref(null)
|
||||
|
||||
// COMPUTED
|
||||
|
||||
@@ -1010,6 +1097,9 @@ const activeView = computed(() => {
|
||||
return 'article'
|
||||
})
|
||||
|
||||
/** Annotating, which is only ever done over the article. */
|
||||
const isAnnotating = computed(() => state.annotating && activeView.value === 'article')
|
||||
|
||||
/*
|
||||
The other providers, at the bottom of the article. No permission check: what a third-party widget
|
||||
shows and to whom is that provider's own business, and this wiki's page rules say nothing about an
|
||||
@@ -1098,9 +1188,45 @@ watch(
|
||||
() => pageStore.id,
|
||||
() => {
|
||||
state.view = viewFromHash()
|
||||
// -> Whatever was being annotated was in the page that has just been left
|
||||
state.annotating = false
|
||||
clearHighlights()
|
||||
}
|
||||
)
|
||||
|
||||
/*
|
||||
View All Annotations goes off whenever the reader goes anywhere: another page, and the editor too.
|
||||
The editor is the same page at another path (`/_edit/...`), so the page id above does not move --
|
||||
but it replaces the article the passages were found in, and the layer left mounted over it would
|
||||
be listening to an element that is gone. A hash moving within the page is not going anywhere.
|
||||
*/
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
state.showAnnotations = false
|
||||
state.revealAnnotationId = null
|
||||
}
|
||||
)
|
||||
|
||||
/*
|
||||
Leaving the page while annotating throws away whatever was picked, so the reader is asked first.
|
||||
Only a change of PATH counts: a heading picked from the contents, or a footnote followed, moves the
|
||||
hash and leaves the reader exactly where the passages are.
|
||||
*/
|
||||
async function guardAnnotations(to, from) {
|
||||
if (!state.annotating || to.path === from.path) {
|
||||
return true
|
||||
}
|
||||
if (!(await confirmStopAnnotating())) {
|
||||
return false
|
||||
}
|
||||
state.annotating = false
|
||||
return true
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(guardAnnotations)
|
||||
onBeforeRouteUpdate(guardAnnotations)
|
||||
|
||||
/*
|
||||
Reading the page is reading what the reader was told about it: its content as soon as it is on
|
||||
screen, its discussion once the Talk tab is open. This is what lets an email go out once and then
|
||||
@@ -1175,11 +1301,19 @@ function onHashChange() {
|
||||
* they move, so holding one down would otherwise start a transition per keypress with nothing
|
||||
* changing in any of them.
|
||||
*/
|
||||
function switchView(view) {
|
||||
async function switchView(view) {
|
||||
if (view === state.view) {
|
||||
return
|
||||
}
|
||||
withViewTransition(() => {
|
||||
// -> Annotating is done over the article, so going anywhere else is the end of it -- asked first,
|
||||
// since it throws away the passages that were picked
|
||||
if (state.annotating && view !== 'article') {
|
||||
if (!(await confirmStopAnnotating())) {
|
||||
return
|
||||
}
|
||||
state.annotating = false
|
||||
}
|
||||
return withViewTransition(() => {
|
||||
state.view = view
|
||||
})
|
||||
}
|
||||
@@ -1492,6 +1626,67 @@ function startNewComment() {
|
||||
talkView.value?.startNewComment()
|
||||
}
|
||||
|
||||
/** Over to the article, with the annotator open on it. */
|
||||
async function startAnnotating() {
|
||||
closeTocPanel()
|
||||
await switchView('article')
|
||||
state.annotating = true
|
||||
}
|
||||
|
||||
function stopAnnotating() {
|
||||
state.annotating = false
|
||||
}
|
||||
|
||||
/** Whether the annotator may close, asking the reader first if it holds anything they wrote. */
|
||||
async function confirmStopAnnotating() {
|
||||
return annotator.value ? annotator.value.confirmDiscard() : true
|
||||
}
|
||||
|
||||
/** Posted: over to the discussion, down to the comment that was just added to it. */
|
||||
async function onAnnotationsPosted(commentId) {
|
||||
state.annotating = false
|
||||
await switchView('talk')
|
||||
scrollToAnchorWhenReady(`#comment-${commentId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* From an annotation in the discussion to its passage in the article: every annotation shown, as View
|
||||
* All Annotations would, and this one scrolled to with its note already open.
|
||||
*
|
||||
* Looked for again on the click rather than trusting the icon the Talk view drew, since the page may
|
||||
* have been re-rendered in between. A passage that cannot be found is said so, and the reader stays
|
||||
* where they are -- the article is not the answer to a passage that is no longer in it.
|
||||
*/
|
||||
async function locateAnnotation(annotation) {
|
||||
const range = pageContents.value
|
||||
? locateAnchor(indexText(pageContents.value), annotation.anchor)
|
||||
: null
|
||||
if (!range) {
|
||||
notify({ type: 'warning', message: t('common.comments.annotationLost') })
|
||||
return
|
||||
}
|
||||
state.showAnnotations = true
|
||||
state.revealAnnotationId = annotation.id
|
||||
await switchView('article')
|
||||
}
|
||||
|
||||
/** View All Annotations, over the article -- or Hide All Annotations, staying on the discussion. */
|
||||
function toggleAllAnnotations() {
|
||||
if (state.showAnnotations) {
|
||||
state.showAnnotations = false
|
||||
return
|
||||
}
|
||||
closeTocPanel()
|
||||
state.showAnnotations = true
|
||||
switchView('article')
|
||||
}
|
||||
|
||||
/** From an annotation's note over the article to the comment it was posted in. */
|
||||
async function viewComment(commentId) {
|
||||
await switchView('talk')
|
||||
scrollToAnchorWhenReady(`#comment-${commentId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the contents panel once the reader has picked something out of it.
|
||||
*
|
||||
|
||||
@@ -53,7 +53,7 @@ export const usePageStore = defineStore('page', {
|
||||
alias: '',
|
||||
/** Whether this page shows its Links tab. The site-wide switch is `siteStore.features.backlinks`. */
|
||||
allowBacklinks: true,
|
||||
allowComments: false,
|
||||
allowComments: true,
|
||||
allowContributions: true,
|
||||
allowRatings: true,
|
||||
authorId: 0,
|
||||
@@ -650,7 +650,7 @@ export const usePageStore = defineStore('page', {
|
||||
localeRelations: [],
|
||||
tags: props.tags ?? [],
|
||||
allowBacklinks: props.allowBacklinks ?? true,
|
||||
allowComments: props.allowComments ?? false,
|
||||
allowComments: props.allowComments ?? true,
|
||||
allowContributions: props.allowContributions ?? true,
|
||||
allowRatings: props.allowRatings ?? true,
|
||||
showLastEditedBy: props.showLastEditedBy ?? true,
|
||||
|
||||
Reference in new issue
Block a user