mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
bfd7243fe2
Summary: First iteration for comments system for Grist. - Comments are stored in a generic metatable `_grist_Cells` - Each comment is connected to a particular cell (hence the generic name of the table) - Access level works naturally for records stored in this table -- User can add/read comments for cells he can see -- User can't update/remove comments that he doesn't own, but he can delete them by removing cells (rows/columns) -- Anonymous users can't see comments at all. - Each comment can have replies (but replies can't have more replies) Comments are hidden by default, they can be enabled by COMMENTS=true env variable. Some things for follow-up - Avatars, currently the user's profile image is not shown or retrieved from the server - Virtual rendering for comments list in creator panel. Currently, there is a limit of 200 comments. Test Plan: New and existing tests Reviewers: georgegevoian, paulfitz Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3509
1383 lines
41 KiB
TypeScript
1383 lines
41 KiB
TypeScript
import {GristDoc} from 'app/client/components/GristDoc';
|
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
|
import {createObsArray} from 'app/client/lib/koArrayWrap';
|
|
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
|
import {CellRec, ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
|
|
import {reportError} from 'app/client/models/errors';
|
|
import {RowSource, RowWatcher} from 'app/client/models/rowset';
|
|
import {createUserImage} from 'app/client/ui/UserImage';
|
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
|
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {menu, menuItem} from 'app/client/ui2018/menus';
|
|
import {CellInfoType} from 'app/common/gristTypes';
|
|
import {FullUser} from 'app/common/UserAPI';
|
|
import {
|
|
bundleChanges,
|
|
Computed,
|
|
Disposable,
|
|
dom,
|
|
DomArg,
|
|
DomContents,
|
|
DomElementArg,
|
|
IDomComponent,
|
|
makeTestId,
|
|
MultiHolder,
|
|
ObsArray,
|
|
Observable,
|
|
styled
|
|
} from 'grainjs';
|
|
import {createPopper, Options as PopperOptions} from '@popperjs/core';
|
|
import * as ko from 'knockout';
|
|
import moment from 'moment';
|
|
import maxSize from 'popper-max-size-modifier';
|
|
import flatMap = require('lodash/flatMap');
|
|
|
|
const testId = makeTestId('test-discussion-');
|
|
const COMMENTS_LIMIT = 200;
|
|
|
|
interface DiscussionPopupProps {
|
|
domEl: Element,
|
|
topic: ICellView,
|
|
discussionId?: number,
|
|
gristDoc: GristDoc,
|
|
closeClicked: () => void;
|
|
}
|
|
|
|
interface ICellView {
|
|
comments: Observable<CellRec[]>,
|
|
comment(text: string): Promise<void>;
|
|
reply(discussion: CellRec, text: string): Promise<void>;
|
|
resolve(discussion: CellRec): Promise<void>;
|
|
update(comment: CellRec, text: string): Promise<void>;
|
|
open(discussion: CellRec): Promise<void>;
|
|
remove(comment: CellRec): Promise<void>;
|
|
}
|
|
|
|
export class CellWithComments extends Disposable implements ICellView {
|
|
public comments: Observable<CellRec[]>;
|
|
|
|
constructor(protected gristDoc: GristDoc) {
|
|
super();
|
|
}
|
|
|
|
public async comment(text: string): Promise<void> {
|
|
// To override
|
|
}
|
|
|
|
public async reply(comment: CellRec, text: string): Promise<void> {
|
|
const author = commentAuthor(this.gristDoc);
|
|
await this.gristDoc.docData.bundleActions("Reply to a comment", () => Promise.all([
|
|
this.gristDoc.docModel.cells.sendTableAction([
|
|
"AddRecord",
|
|
null,
|
|
{
|
|
parentId: comment.id.peek(),
|
|
root: false,
|
|
type: CellInfoType.COMMENT,
|
|
userRef: author?.ref ?? '',
|
|
content: JSON.stringify({
|
|
userName: author?.name ?? '',
|
|
timeCreated: Date.now(),
|
|
timeUpdated: null,
|
|
text
|
|
}),
|
|
tableRef: comment.tableRef.peek(),
|
|
colRef: comment.colRef.peek(),
|
|
rowId: comment.rowId.peek(),
|
|
}
|
|
])
|
|
]));
|
|
}
|
|
public resolve(comment: CellRec): Promise<void> {
|
|
const author = commentAuthor(this.gristDoc);
|
|
comment.resolved(true);
|
|
comment.resolvedBy(author?.email ?? '');
|
|
return comment.timeUpdated.setAndSave((Date.now()));
|
|
}
|
|
|
|
public async update(comment: CellRec, text: string): Promise<void> {
|
|
const timeUpdated = Date.now();
|
|
comment.text(text.trim());
|
|
return comment.timeUpdated.setAndSave(timeUpdated);
|
|
}
|
|
public async open(comment: CellRec): Promise<void> {
|
|
comment.resolved(false);
|
|
comment.resolvedBy('');
|
|
return comment.timeUpdated.setAndSave((Date.now()));
|
|
}
|
|
|
|
public async remove(comment: CellRec): Promise<void> {
|
|
await comment._table.sendTableAction(["RemoveRecord", comment.id.peek()]);
|
|
}
|
|
}
|
|
|
|
export class EmptyCell extends CellWithComments implements ICellView {
|
|
constructor(public props: {
|
|
gristDoc: GristDoc,
|
|
column: ColumnRec,
|
|
rowId: number,
|
|
tableRef: number,
|
|
}) {
|
|
super(props.gristDoc);
|
|
const {column, rowId} = props;
|
|
this.comments = Computed.create(this, use => {
|
|
const fromColumn = use(use(column.cells).getObservable());
|
|
const forRow = fromColumn.filter(d => use(d.rowId) === rowId && use(d.root) && !use(d.hidden));
|
|
return forRow;
|
|
});
|
|
}
|
|
public async comment(text: string): Promise<void> {
|
|
const props = this.props;
|
|
const author = commentAuthor(props.gristDoc);
|
|
const now = Date.now();
|
|
const addComment = [
|
|
"AddRecord",
|
|
"_grist_Cells",
|
|
null,
|
|
{
|
|
tableRef: props.tableRef,
|
|
colRef: props.column.id.peek(),
|
|
rowId: props.rowId,
|
|
type: CellInfoType.COMMENT,
|
|
root: true,
|
|
userRef: author?.ref ?? '',
|
|
content: JSON.stringify({
|
|
timeCreated: now,
|
|
text: text,
|
|
userName: author?.name ?? '',
|
|
})
|
|
}
|
|
];
|
|
await props.gristDoc.docData.sendActions([addComment], 'Started discussion');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discussion popup that is attached to a cell.
|
|
*/
|
|
export class CellDiscussionPopup extends Disposable {
|
|
private _isEmpty: Computed<boolean>;
|
|
|
|
constructor(public props: DiscussionPopupProps) {
|
|
super();
|
|
this._isEmpty = Computed.create(this, use => {
|
|
const discussions = use(props.topic.comments);
|
|
const notResolved = discussions.filter(d => !use(d.resolved));
|
|
const visible = notResolved.filter(d => !use(d.hidden));
|
|
return visible.length === 0;
|
|
});
|
|
const content = dom('div',
|
|
testId('popup'),
|
|
dom.domComputed(use => use(this._isEmpty), empty => {
|
|
if (!empty) {
|
|
return dom.create(CellWithCommentsView, {
|
|
topic: props.topic,
|
|
readonly: props.gristDoc.isReadonly,
|
|
gristDoc: props.gristDoc,
|
|
panel: false,
|
|
closeClicked: props.closeClicked,
|
|
});
|
|
} else {
|
|
return dom.create(EmptyCellView, {
|
|
closeClicked: props.closeClicked,
|
|
onSave: (text) => this.props.topic.comment(text),
|
|
});
|
|
}
|
|
})
|
|
);
|
|
buildPopup(this, props.domEl, content, cellPopperOptions, this.props.closeClicked);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Component for starting discussion on a cell. Displays simple textbox and a button to start discussion.
|
|
*/
|
|
class EmptyCellView extends Disposable {
|
|
private _newText = Observable.create(this, '');
|
|
|
|
constructor(public props: {
|
|
closeClicked: () => void,
|
|
onSave: (text: string) => any
|
|
}) {
|
|
super();
|
|
}
|
|
|
|
public buildDom() {
|
|
return cssTopic(
|
|
testId('topic-empty'),
|
|
testId('topic'),
|
|
this._createCommentEntry(),
|
|
dom.onKeyDown({
|
|
Escape: () => this.props.closeClicked?.(),
|
|
})
|
|
);
|
|
}
|
|
|
|
private _createCommentEntry() {
|
|
return cssCommonPadding(dom.create(CommentEntry, {
|
|
mode: 'start',
|
|
text: this._newText,
|
|
onSave: () => this.props.onSave(this._newText.get()),
|
|
onCancel: () => this.props.closeClicked?.(),
|
|
editorArgs: [{placeholder: 'Write a comment'}],
|
|
mainButton: 'Comment',
|
|
buttons: ['Cancel'],
|
|
args: [testId('editor-start')]
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main component for displaying discussion on a popup.
|
|
*/
|
|
class CellWithCommentsView extends Disposable implements IDomComponent {
|
|
// Holder for a new comment text.
|
|
private _newText = Observable.create(this, '');
|
|
// CommentList dom - used for scrolling.
|
|
private _discussionList!: HTMLDivElement;
|
|
// Currently edited comment.
|
|
private _commentInEdit = Observable.create<CommentView | null>(this, null);
|
|
// Helper variable to mitigate some flickering when closing editor.
|
|
// We hide the editor before resolving discussion or clearing discussion, as
|
|
// actions that create discussions and comments are asynchronous, so user can see
|
|
// that comments elements are removed.
|
|
private _closing = Observable.create(this, false);
|
|
private _comments: Observable<CellRec[]>;
|
|
private _commentsToRender: Observable<CellRec[]>;
|
|
private _truncated: Observable<boolean>;
|
|
|
|
constructor(public props: {
|
|
topic: ICellView,
|
|
readonly: Observable<boolean>,
|
|
gristDoc: GristDoc,
|
|
panel?: boolean,
|
|
closeClicked?: () => void
|
|
}) {
|
|
super();
|
|
if (props.panel) {
|
|
this._comments = Computed.create(this, use =>
|
|
use(props.topic.comments).filter(ds => !use(ds.hidden) && use(ds.root)));
|
|
} else {
|
|
// Don't show resolved comments on a popup.
|
|
this._comments = Computed.create(this,
|
|
use => use(props.topic.comments).filter(ds => !use(ds.resolved) && !use(ds.hidden) && use(ds.root)));
|
|
}
|
|
this._commentsToRender = Computed.create(this, use => {
|
|
const sorted = use(this._comments).sort((a, b) => (use(a.timeCreated) ?? 0) - (use(b.timeCreated) ?? 0));
|
|
const start = Math.max(0, sorted.length - COMMENTS_LIMIT);
|
|
return sorted.slice(start);
|
|
});
|
|
this._truncated = Computed.create(this, use => use(this._comments).length > COMMENTS_LIMIT);
|
|
}
|
|
|
|
public buildDom() {
|
|
return cssTopic(
|
|
dom.maybe(this._truncated, () => cssTruncate(`Showing last ${COMMENTS_LIMIT} comments`)),
|
|
cssTopic.cls('-panel', this.props.panel),
|
|
domOnCustom(CommentView.EDIT, (s: CommentView) => this._onEditComment(s)),
|
|
domOnCustom(CommentView.CANCEL, (s: CommentView) => this._onCancelEdit()),
|
|
dom.hide(this._closing),
|
|
testId('topic'),
|
|
testId('topic-filled'),
|
|
dom.maybe(!this.props.panel, () =>
|
|
cssHeaderBox(
|
|
testId('topic-header'),
|
|
// NOT IMPLEMENTED YET
|
|
// cssHoverButton(cssRotate('Expand'), dom.on('click', () => this._remove()), {title: 'Previous discussion'}),
|
|
// cssHoverButton(icon('Expand'), dom.on('click', () => this._remove()), {title: 'Next discussion'}),
|
|
dom('div', dom.style('align-self', 'center'), "Comments"),
|
|
cssSpacer(),
|
|
// NOT IMPLEMENTED YET
|
|
// cssIconButton(
|
|
// icon('Dots'),
|
|
// menu(() => [], {placement: 'bottom-start'}),
|
|
// dom.on('click', stopPropagation)
|
|
// ),
|
|
cssHoverButton(
|
|
icon('Popup'),
|
|
testId('topic-button-panel'),
|
|
dom.on('click', () => this.props.gristDoc.showTool('discussion')),
|
|
{title: 'Open panel'}
|
|
),
|
|
cssHeaderBox.cls("-border")
|
|
)),
|
|
this._discussionList = cssCommentList(
|
|
testId('topic-comments'),
|
|
dom.forEach(this._commentsToRender, comment => {
|
|
return cssDiscussionWrapper(
|
|
cssDiscussion(
|
|
cssDiscussion.cls("-resolved", use => Boolean(use(comment.resolved))),
|
|
dom.create(CommentView, {
|
|
...this.props,
|
|
comment
|
|
})
|
|
)
|
|
);
|
|
}
|
|
)
|
|
),
|
|
!this.props.panel ? this._createCommentEntry() : null,
|
|
dom.onKeyDown({
|
|
Escape: () => this.props.closeClicked?.(),
|
|
})
|
|
);
|
|
}
|
|
|
|
private _onCancelEdit() {
|
|
if (this._commentInEdit.get()) {
|
|
this._commentInEdit.get()?.isEditing.set(false);
|
|
}
|
|
this._commentInEdit.set(null);
|
|
}
|
|
|
|
private _onEditComment(el: CommentView) {
|
|
if (this._commentInEdit.get()) {
|
|
this._commentInEdit.get()?.isEditing.set(false);
|
|
}
|
|
el.isEditing.set(true);
|
|
this._commentInEdit.set(el);
|
|
}
|
|
|
|
private async _save() {
|
|
try {
|
|
return await this.props.topic.comment(this._newText.get().trim());
|
|
} catch (err) {
|
|
return reportError(err);
|
|
} finally {
|
|
this._newText.set('');
|
|
this._discussionList.scrollTo(0, 10000);
|
|
}
|
|
}
|
|
|
|
private _createCommentEntry() {
|
|
return cssReplyBox(dom.create(CommentEntry, {
|
|
mode: 'comment',
|
|
text: this._newText,
|
|
onSave: () => this._save(),
|
|
onCancel: () => this.props.closeClicked?.(),
|
|
mainButton: 'Send',
|
|
editorArgs: [{placeholder: 'Comment'}],
|
|
args: [testId('editor-add')]
|
|
}));
|
|
}
|
|
}
|
|
|
|
interface CommentProps {
|
|
comment: CellRec,
|
|
topic: ICellView,
|
|
gristDoc: GristDoc,
|
|
isReply?: boolean,
|
|
panel?: boolean,
|
|
args?: DomArg<HTMLDivElement>[]
|
|
}
|
|
|
|
/**
|
|
* Component for displaying a single comment, either in popup or discussion panel.
|
|
*/
|
|
class CommentView extends Disposable {
|
|
// Public custom events. Those are propagated to the parent component (TopicView) to make
|
|
// sure only one comment is in edit mode at a time.
|
|
public static EDIT = 'comment-edit'; // comment is in edit mode
|
|
public static CANCEL = 'comment-cancel'; // edit mode was cancelled or turned off
|
|
public static SELECT = 'comment-select'; // comment was clicked
|
|
// Public modes that are modified by topic view.
|
|
public isEditing = Observable.create(this, false);
|
|
public replying = Observable.create(this, false);
|
|
private _replies: ObsArray<CellRec>;
|
|
private _hasReplies: Computed<boolean>;
|
|
private _expanded = Observable.create(this, false);
|
|
private _resolved: Computed<boolean>;
|
|
private _showReplies: Computed<boolean>;
|
|
private _bodyDom: Element;
|
|
constructor(
|
|
public props: CommentProps) {
|
|
super();
|
|
this._replies = createObsArray(this, props.comment.children());
|
|
this._hasReplies = Computed.create(this, use => use(this._replies).length > 0);
|
|
this._resolved = Computed.create(this, use => Boolean(use(props.comment.resolved)));
|
|
this._showReplies = Computed.create(this, use => {
|
|
return !this.props.isReply && use(this._replies).length > 0 &&
|
|
(use(this._expanded) || !use(this.props.comment.resolved));
|
|
});
|
|
}
|
|
public buildDom() {
|
|
const comment = this.props.comment;
|
|
const topic = this.props.topic;
|
|
const user = (c: CellRec) =>
|
|
comment.hidden() ? null : commentAuthor(this.props.gristDoc, c.userRef(), c.userName());
|
|
this._bodyDom = cssComment(
|
|
...(this.props.args ?? []),
|
|
this.props.isReply ? testId('reply') : testId('comment'),
|
|
dom.on('click', () => {
|
|
if (this.props.isReply) { return; }
|
|
trigger(this._bodyDom, CommentView.SELECT, comment);
|
|
if (!this._resolved.get()) { return; }
|
|
this._expanded.set(!this._expanded.get());
|
|
}),
|
|
dom.maybe(use => !use(comment.hidden), () => [
|
|
cssColumns(
|
|
// 1. Column with avatar only
|
|
buildAvatar(user(comment), testId('comment-avatar')),
|
|
// 2. Column with nickname/date, menu and text
|
|
cssCommentHeader(
|
|
// User name date and buttons
|
|
cssCommentBodyHeader(
|
|
dom('div',
|
|
buildNick(user(comment), testId('comment-nick')),
|
|
dom.domComputed(use => cssTime(
|
|
formatTime(use(comment.timeUpdated) ?? use(comment.timeCreated) ?? 0),
|
|
testId('comment-time'),
|
|
)),
|
|
),
|
|
cssSpacer(),
|
|
cssIconButton(
|
|
icon('Dots'),
|
|
testId('comment-menu'),
|
|
dom.style('margin-left', `3px`),
|
|
menu(() => this._menuItems(), {placement: 'bottom-start'}),
|
|
dom.on('click', stopPropagation)
|
|
)
|
|
),
|
|
),
|
|
),
|
|
// Comment text
|
|
dom.maybe(use => !use(this.isEditing),
|
|
() => dom.domComputed(comment.hidden, (hidden) => {
|
|
if (hidden) {
|
|
return dom('div',
|
|
"CENSORED",
|
|
{style: 'margin-top: 4px'},
|
|
testId('comment-text'),
|
|
);
|
|
}
|
|
return cssCommentPre(
|
|
dom.text(use => use(comment.text) ?? ''),
|
|
{style: 'margin-top: 4px'},
|
|
testId('comment-text'),
|
|
);
|
|
})
|
|
),
|
|
// Comment editor
|
|
dom.maybeOwned(this.isEditing,
|
|
(owner) => {
|
|
const text = Observable.create(owner, comment.text.peek() ?? '');
|
|
return dom.create(CommentEntry, {
|
|
text,
|
|
mainButton: 'Save',
|
|
buttons: ['Cancel'],
|
|
onSave: async () => {
|
|
const value = text.get();
|
|
text.set("");
|
|
await topic.update(comment, value);
|
|
trigger(this._bodyDom, CommentView.CANCEL, this);
|
|
this.isEditing.set(false);
|
|
},
|
|
onCancel: () => {
|
|
trigger(this._bodyDom, CommentView.CANCEL, this);
|
|
this.isEditing.set(false);
|
|
},
|
|
mode: 'start',
|
|
args: [testId('editor-edit')]
|
|
});
|
|
}
|
|
),
|
|
dom.maybe(this._showReplies, () =>
|
|
cssCommentReplyWrapper(
|
|
testId('replies'),
|
|
cssReplyList(
|
|
dom.forEach(this._replies, (commentReply) => {
|
|
return dom('div',
|
|
dom.create(CommentView, {
|
|
...this.props,
|
|
comment: commentReply,
|
|
isReply: true,
|
|
args: [dom.style('padding-left', '0px'), dom.style('padding-right', '0px')],
|
|
})
|
|
);
|
|
}),
|
|
)
|
|
)
|
|
),
|
|
// Reply editor or button
|
|
dom.maybe(use => !use(this.isEditing) && !this.props.isReply && !use(comment.resolved),
|
|
() => dom.domComputed(use => {
|
|
if (!use(this.replying)) {
|
|
return cssReplyButton(icon('Message'), 'Reply',
|
|
testId('comment-reply-button'),
|
|
dom.on('click', withStop(() => this.replying.set(true))),
|
|
dom.style('margin-left', use2 => use2(this._hasReplies) ? '16px' : '0px'),
|
|
);
|
|
} else {
|
|
const text = Observable.create(null, '');
|
|
return dom.create(CommentEntry, {
|
|
text,
|
|
args: [dom.style('margin-top', '8px'), testId('editor-reply')],
|
|
mainButton: 'Reply',
|
|
buttons: ['Cancel'],
|
|
onSave: async () => {
|
|
const value = text.get();
|
|
this.replying.set(false);
|
|
await topic.reply(comment, value);
|
|
},
|
|
onCancel: () => this.replying.set(false),
|
|
onClick: (button) => {
|
|
if (button === 'Cancel') {
|
|
this.replying.set(false);
|
|
}
|
|
},
|
|
mode: 'reply'
|
|
});
|
|
}
|
|
})
|
|
),
|
|
// Resolved marker
|
|
dom.domComputed((use) => {
|
|
if (!use(comment.resolved) || this.props.isReply) { return null; }
|
|
return cssResolvedBlock(
|
|
testId('comment-resolved'),
|
|
icon('FieldChoice'),
|
|
cssResolvedText(dom.text(
|
|
`Marked as resolved`
|
|
)));
|
|
}),
|
|
]),
|
|
);
|
|
|
|
return this._bodyDom;
|
|
}
|
|
private _menuItems() {
|
|
const currentUser = this.props.gristDoc.app.topAppModel.appObs.get()?.currentUser?.ref;
|
|
const canResolve = !this.props.comment.resolved() && !this.props.isReply;
|
|
const comment = this.props.comment;
|
|
return [
|
|
!canResolve ? null :
|
|
menuItem(
|
|
() => this.props.topic.resolve(this.props.comment),
|
|
'Resolve'
|
|
),
|
|
!comment.resolved() ? null :
|
|
menuItem(
|
|
() => this.props.topic.open(comment),
|
|
'Open'
|
|
),
|
|
menuItem(
|
|
() => this.props.topic.remove(comment),
|
|
'Remove',
|
|
dom.cls('disabled', use => {
|
|
return currentUser !== use(comment.userRef);
|
|
})
|
|
),
|
|
menuItem(
|
|
() => this._edit(),
|
|
'Edit',
|
|
dom.cls('disabled', use => {
|
|
return currentUser !== use(comment.userRef);
|
|
})
|
|
),
|
|
];
|
|
}
|
|
|
|
private _edit() {
|
|
trigger(this._bodyDom, CommentView.EDIT, this);
|
|
this.isEditing.set(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Component for displaying input element for a comment (either for replying or starting a new discussion).
|
|
*/
|
|
class CommentEntry extends Disposable {
|
|
constructor(public props: {
|
|
text: Observable<string>,
|
|
mode?: 'comment' | 'start' | 'reply', // inline for reply, full for new discussion
|
|
onClick?: (button: string) => void,
|
|
onSave?: () => Promise<void>|void,
|
|
onCancel?: () => void, // On Escape
|
|
mainButton?: string, // Text for the main button (defaults to Send)
|
|
buttons?: string[], // Additional buttons to show.
|
|
editorArgs?: DomArg<HTMLTextAreaElement>[]
|
|
args?: DomArg<HTMLDivElement>[]
|
|
}) {
|
|
super();
|
|
}
|
|
|
|
public buildDom() {
|
|
const text = this.props.text;
|
|
const clickBuilder = (button: string) => dom.on('click', () => {
|
|
if (button === "Cancel") {
|
|
this.props.onCancel?.();
|
|
} else {
|
|
this.props.onClick?.(button);
|
|
}
|
|
});
|
|
const onSave = async () => text.get() ? await this.props.onSave?.() : {};
|
|
let textArea!: HTMLElement;
|
|
return cssCommentEntry(
|
|
...(this.props.args ?? []),
|
|
cssCommentEntry.cls(`-${this.props.mode ?? 'comment'}`),
|
|
testId('comment-input'),
|
|
dom.on('click', stopPropagation),
|
|
textArea = buildTextEditor(
|
|
text,
|
|
cssCommentEntryText.cls(""),
|
|
cssTextArea.cls(`-${this.props.mode}`),
|
|
dom.onKeyDown({
|
|
Enter$: async (e) => {
|
|
// Save on ctrl+enter
|
|
if (e.ctrlKey && text.get().trim()) {
|
|
await onSave?.();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
},
|
|
Escape: (e) => {
|
|
this.props.onCancel?.();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
}),
|
|
...(this.props.editorArgs || []),
|
|
testId('textarea'),
|
|
),
|
|
elem => {
|
|
FocusLayer.create(this, {
|
|
defaultFocusElem: textArea,
|
|
allowFocus: (e) => (e !== document.body),
|
|
pauseMousetrap: true
|
|
});
|
|
},
|
|
cssCommentEntryButtons(
|
|
primaryButton(
|
|
this.props.mainButton ?? 'Send',
|
|
dom.prop('disabled', use => !use(text).trim()),
|
|
dom.on('click', withStop(onSave)),
|
|
testId('button-send'),
|
|
),
|
|
dom.forEach(this.props.buttons || [], button => basicButton(
|
|
button, clickBuilder(button), testId(`button-${button}`)
|
|
)),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Component that is rendered on the right drawer. It shows all discussions in the document or on the
|
|
* current page. By current page, we mean comments in all currently visible rows (that are not filtered out).
|
|
*/
|
|
export class DiscussionPanel extends Disposable implements IDomComponent {
|
|
// View mode - current page or whole document.
|
|
private _currentPage: Observable<boolean>;
|
|
private _currentPageKo: ko.Observable<boolean>;
|
|
private _onlyMine: Observable<boolean>;
|
|
// Toggle to switch whether to show active discussions or all discussions (including resolved ones).
|
|
private _resolved: Observable<boolean>;
|
|
private _length = Observable.create<number>(this, 0);
|
|
|
|
constructor(private _grist: GristDoc) {
|
|
super();
|
|
const userId = _grist.app.topAppModel.appObs.get()?.currentUser?.id || 0;
|
|
// We store options in session storage, so that they are preserved across page reloads.
|
|
this._resolved = this.autoDispose(localStorageBoolObs(`u:${userId};showResolvedDiscussions`, false));
|
|
this._onlyMine = this.autoDispose(localStorageBoolObs(`u:${userId};showMyDiscussions`, false));
|
|
this._currentPage = this.autoDispose(localStorageBoolObs(`u:${userId};showCurrentPage`, true));
|
|
this._currentPageKo = ko.observable(this._currentPage.get());
|
|
this._currentPage.addListener(val => this._currentPageKo(val));
|
|
}
|
|
|
|
public buildDom(): DomContents {
|
|
const owner = new MultiHolder();
|
|
|
|
// Computed for all sections visible on the page.
|
|
const viewSections = Computed.create(owner, use => {
|
|
return use(use(this._grist.viewModel.viewSections).getObservable());
|
|
});
|
|
|
|
// Based on the view, we get all tables or only visible ones.
|
|
const tables = Computed.create(owner, use => {
|
|
// Filter out those tables that are not available by ACL.
|
|
if (use(this._currentPageKo)) {
|
|
return [...new Set(use(viewSections).map(vs => use(vs.table)).filter(t => use(t.tableId)))];
|
|
} else {
|
|
return use(this._grist.docModel.visibleTables.getObservable()).filter(t => use(t.tableId));
|
|
}
|
|
});
|
|
|
|
// Column filter - only show discussions in this column (depending on the mode).
|
|
const columnFilter = Computed.create(owner, use => {
|
|
if (use(this._currentPageKo)) {
|
|
const fieldSet = new Set<number>();
|
|
use(viewSections).forEach(vs => {
|
|
use(use(vs.viewFields).getObservable()).forEach(vf => fieldSet.add(use(vf.colRef)));
|
|
});
|
|
return (ds: CellRec) => {
|
|
return fieldSet.has(use(ds.colRef));
|
|
};
|
|
} else {
|
|
return () => true;
|
|
}
|
|
});
|
|
|
|
// Create a row filter based on user filters (rows that user actually see).
|
|
const watcher = RowWatcher.create(owner);
|
|
watcher.rowFilter.set(() => true);
|
|
// Now watch for viewSections (when they are changed, and then update watcher instance).
|
|
// Unfortunately, we can't use _viewSections here because GrainJS has a different
|
|
// behavior than ko when one observable changes during the evaluation. Here viewInstance
|
|
// will probably be set during computations. To fix this we need a ko.observable here.
|
|
const sources = owner.autoDispose(ko.computed(() => {
|
|
if (this._currentPageKo()) {
|
|
const list: RowSource[] = [];
|
|
for (const vs of this._grist.viewModel.viewSections().all()) {
|
|
const viewInstance = vs.viewInstance();
|
|
if (viewInstance) {
|
|
list.push(viewInstance.rowSource);
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
return null;
|
|
}));
|
|
sources.peek()?.forEach(source => watcher.subscribeTo(source));
|
|
owner.autoDispose(sources.subscribe(list => {
|
|
bundleChanges(() => {
|
|
watcher.clear();
|
|
if (list) {
|
|
list.forEach(source => watcher.subscribeTo(source));
|
|
} else {
|
|
// Page
|
|
watcher.rowFilter.set(() => true);
|
|
}
|
|
});
|
|
}));
|
|
|
|
const rowFilter = watcher.rowFilter;
|
|
|
|
const discussionFilter = Computed.create(owner, use => {
|
|
const filterRow = use(rowFilter);
|
|
const filterCol = use(columnFilter);
|
|
const showAll = use(this._resolved);
|
|
const showAnyone = !use(this._onlyMine);
|
|
const currentUser = use(this._grist.app.topAppModel.appObs)?.currentUser?.email ?? '';
|
|
const userFilter = (d: CellRec) => {
|
|
const replies = use(use(d.children).getObservable());
|
|
return use(d.userRef) === currentUser || replies.some(c => use(c.userRef) === currentUser);
|
|
};
|
|
return (ds: CellRec) =>
|
|
!use(ds.hidden) // filter by ACL
|
|
&& filterRow(use(ds.rowId))
|
|
&& filterCol(ds)
|
|
&& (showAnyone || userFilter(ds))
|
|
&& (showAll || !use(ds.resolved))
|
|
;
|
|
});
|
|
const allDiscussions = Computed.create(owner, use => {
|
|
const list = flatMap(flatMap(use(tables).map(t => {
|
|
const columns = use(use(t.columns).getObservable());
|
|
const dList = columns.map(col => use(use(col.cells).getObservable())
|
|
.filter(c => use(c.root) && use(c.type) === CellInfoType.COMMENT));
|
|
return dList;
|
|
})));
|
|
return list;
|
|
});
|
|
const discussions = Computed.create(owner, use => {
|
|
const all = use(allDiscussions);
|
|
const filter = use(discussionFilter);
|
|
return all.filter(filter);
|
|
});
|
|
const topic = CellWithComments.create(owner, this._grist);
|
|
topic.comments = discussions;
|
|
owner.autoDispose(discussions.addListener((d) => this._length.set(d.length)));
|
|
this._length.set(discussions.get().length);
|
|
// Selector for page all whole document.
|
|
return cssDiscussionPanel(
|
|
dom.autoDispose(owner),
|
|
testId('panel'),
|
|
// Discussion list - actually we are showing first comment of each discussion.
|
|
cssDiscussionPanelList(
|
|
dom.create(CellWithCommentsView, {
|
|
readonly: this._grist.isReadonly,
|
|
gristDoc: this._grist,
|
|
topic: topic,
|
|
panel: true
|
|
})
|
|
),
|
|
domOnCustom(CommentView.SELECT, (ds: CellRec) => {
|
|
this._navigate(ds).catch(() => {});
|
|
})
|
|
);
|
|
}
|
|
|
|
public buildMenu(): DomContents {
|
|
return cssPanelHeader(
|
|
dom('span', dom.text(use => `${use(this._length)} comments`), testId('comment-count')),
|
|
cssIconButtonMenu(
|
|
icon('Dots'),
|
|
testId('panel-menu'),
|
|
menu(() => {
|
|
return [cssDropdownMenu(
|
|
labeledSquareCheckbox(this._onlyMine, "Only my threads", testId('my-threads')),
|
|
labeledSquareCheckbox(this._currentPage, "Only current page", testId('only-page')),
|
|
labeledSquareCheckbox(this._resolved, "Show resolved comments", testId('show-resolved')),
|
|
)];
|
|
}, {placement: 'bottom-start'}),
|
|
dom.on('click', stopPropagation)
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Navigates to cell on current page or opens discussion next to the panel.
|
|
*/
|
|
private async _navigate(discussion: CellRec) {
|
|
// Try to find the cell on the current page.
|
|
const rowId = discussion.rowId.peek();
|
|
function findSection(viewSections: ViewSectionRec[]) {
|
|
const section = viewSections
|
|
.filter(s => s.tableRef.peek() === discussion.tableRef.peek())
|
|
.filter(s => s.viewFields.peek().all().find(f => f.colRef.peek() === discussion.colRef.peek()))[0];
|
|
const sectionId = section?.getRowId();
|
|
const fieldIndex = section?.viewFields.peek().all()
|
|
.findIndex(f => f.colRef.peek() === discussion.colRef.peek()) ?? -1;
|
|
if (fieldIndex !== -1) {
|
|
return {sectionId, fieldIndex};
|
|
}
|
|
return null;
|
|
}
|
|
let sectionId = 0;
|
|
let fieldIndex = -1;
|
|
const section = findSection(this._grist.viewModel.viewSections.peek().all());
|
|
// If we haven't found the cell on the current page, try other pages.
|
|
if (!section) {
|
|
for (const pageId of this._grist.docModel.pages.getAllRows()) {
|
|
const page = this._grist.docModel.pages.getRowModel(pageId);
|
|
const vss = page.view.peek().viewSections.peek().all();
|
|
const result = findSection(vss);
|
|
if (result) {
|
|
sectionId = result.sectionId;
|
|
fieldIndex = result.fieldIndex;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
sectionId = section.sectionId;
|
|
fieldIndex = section.fieldIndex;
|
|
}
|
|
|
|
if (!sectionId) {
|
|
return;
|
|
}
|
|
|
|
const currentPosition = this._grist.cursorPosition.get();
|
|
|
|
if (currentPosition?.sectionId === sectionId &&
|
|
currentPosition.fieldIndex === fieldIndex &&
|
|
currentPosition.rowId === rowId) {
|
|
return;
|
|
}
|
|
|
|
// Navigate cursor to the cell.
|
|
const ok = await this._grist.recursiveMoveToCursorPos({
|
|
rowId,
|
|
sectionId,
|
|
fieldIndex
|
|
}, true);
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildTextEditor(text: Observable<string>, ...args: DomArg<HTMLTextAreaElement>[]) {
|
|
const textArea = cssTextArea(
|
|
bindProp(text),
|
|
autoFocus(),
|
|
autoGrow(text),
|
|
...args
|
|
);
|
|
return textArea;
|
|
}
|
|
|
|
|
|
function buildAvatar(user: FullUser | null, ...args: DomElementArg[]) {
|
|
return cssAvatar(user, 'small', ...args);
|
|
}
|
|
|
|
function buildNick(user: {name: string} | null, ...args: DomArg<HTMLElement>[]) {
|
|
return cssNick(user?.name ?? 'Anonymous', ...args);
|
|
}
|
|
|
|
function bindProp(text: Observable<string>) {
|
|
return [
|
|
dom.prop('value', text),
|
|
dom.on('input', (_, el: HTMLTextAreaElement) => text.set(el.value)),
|
|
];
|
|
}
|
|
|
|
function autoFocus() {
|
|
return (el: HTMLElement) => void setTimeout(() => el.focus(), 10);
|
|
}
|
|
|
|
function resize(el: HTMLTextAreaElement) {
|
|
el.style.height = '5px'; // hack for triggering style update.
|
|
const border = getComputedStyle(el, null).borderTopWidth || "0";
|
|
el.style.height = `calc(${el.scrollHeight}px + 2 * ${border})`;
|
|
}
|
|
|
|
function autoGrow(text: Observable<string>) {
|
|
return (el: HTMLTextAreaElement) => {
|
|
el.addEventListener('input', () => resize(el));
|
|
setTimeout(() => resize(el), 10);
|
|
dom.autoDisposeElem(el, text.addListener(val => {
|
|
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)
|
|
// So we need to manually update the textarea when the text is cleared.
|
|
if (!val) {
|
|
el.style.height = '5px'; // there is a min-height css attribute, so this is only to trigger a style update.
|
|
}
|
|
}));
|
|
};
|
|
}
|
|
|
|
function buildPopup(
|
|
owner: Disposable,
|
|
cell: Element,
|
|
content: HTMLElement,
|
|
options: Partial<PopperOptions>,
|
|
closeClicked: () => void
|
|
) {
|
|
const popper = createPopper(cell, content, options);
|
|
owner.onDispose(() => popper.destroy());
|
|
document.body.appendChild(content);
|
|
owner.onDispose(() => { dom.domDispose(content); content.remove(); });
|
|
owner.autoDispose(onClickOutside(content, () => closeClicked()));
|
|
}
|
|
|
|
// Helper binding function to handle click outside an element. Takes into account floating menus.
|
|
function onClickOutside(content: HTMLElement, click: () => void) {
|
|
const onClick = (evt: MouseEvent) => {
|
|
const target: Node | null = evt.target as Node;
|
|
if (target && !content.contains(target)) {
|
|
// Check if any parent of target has class grist-floating-menu, if so, don't close.
|
|
if (target.parentElement?.closest(".grist-floating-menu")) {
|
|
return;
|
|
}
|
|
click();
|
|
}
|
|
};
|
|
return dom.onElem(document, 'click', onClick, {useCapture: true});
|
|
}
|
|
|
|
// Display timestamp as a relative time ago using moment.js
|
|
function formatTime(timeStamp: number) {
|
|
const time = moment(timeStamp);
|
|
const now = moment();
|
|
const diff = now.diff(time, 'days');
|
|
if (diff < 1) {
|
|
return time.fromNow();
|
|
}
|
|
return time.format('MMM D, YYYY');
|
|
}
|
|
|
|
function commentAuthor(grist: GristDoc, userRef?: string, userName?: string): FullUser | null {
|
|
if (!userRef) {
|
|
const loggedInUser = grist.app.topAppModel.appObs.get()?.currentValidUser;
|
|
if (!loggedInUser) {
|
|
return {
|
|
name: userName || '',
|
|
ref: userRef || '',
|
|
email: '',
|
|
id: 0
|
|
};
|
|
}
|
|
if (!loggedInUser.ref) {
|
|
throw new Error("User reference is not set");
|
|
}
|
|
return loggedInUser;
|
|
} else {
|
|
if (typeof userName !== 'string') {
|
|
return null;
|
|
}
|
|
return {
|
|
name: userName,
|
|
ref: userRef || '',
|
|
email: '',
|
|
id: 0
|
|
};
|
|
}
|
|
}
|
|
|
|
// Options for popper.js
|
|
const calcMaxSize = {
|
|
...maxSize,
|
|
options: {padding: 4},
|
|
};
|
|
const applyMaxSize: any = {
|
|
name: 'applyMaxSize',
|
|
enabled: true,
|
|
phase: 'beforeWrite',
|
|
requires: ['maxSize'],
|
|
fn({state}: any) {
|
|
// The `maxSize` modifier provides this data
|
|
const {height} = state.modifiersData.maxSize;
|
|
Object.assign(state.styles.popper, {
|
|
maxHeight: `${Math.min(Math.max(250, height), 600)}px`
|
|
});
|
|
}
|
|
};
|
|
const cellPopperOptions: Partial<PopperOptions> = {
|
|
placement: 'bottom',
|
|
strategy: 'fixed',
|
|
modifiers: [
|
|
calcMaxSize,
|
|
applyMaxSize,
|
|
{
|
|
name: 'offset',
|
|
options: {
|
|
offset: [0, 4],
|
|
},
|
|
},
|
|
{name: "computeStyles", options: {gpuAcceleration: false}},
|
|
{name: 'eventListeners', enabled: false}
|
|
],
|
|
};
|
|
|
|
|
|
function stopPropagation(ev: Event) {
|
|
ev.stopPropagation();
|
|
}
|
|
|
|
function withStop(handler: () => any) {
|
|
return (ev: Event) => {
|
|
stopPropagation(ev);
|
|
handler();
|
|
};
|
|
}
|
|
|
|
const cssAvatar = styled(createUserImage, `
|
|
flex: none;
|
|
margin-top: 2px;
|
|
`);
|
|
|
|
|
|
const cssDiscussionPanel = styled('div', `
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
overflow: auto;
|
|
padding: 8px;
|
|
`);
|
|
|
|
const cssDiscussionPanelList = styled('div', `
|
|
margin-bottom: 0px;
|
|
`);
|
|
|
|
const cssCommonPadding = styled('div', `
|
|
padding: 16px;
|
|
`);
|
|
|
|
const cssPanelHeader = styled('div', `
|
|
display: flex;
|
|
flex: 1;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
`);
|
|
|
|
const cssDropdownMenu = styled('div', `
|
|
display: flex;
|
|
padding: 12px;
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
gap: 10px;
|
|
flex-direction: column;
|
|
`);
|
|
|
|
const cssReplyBox = styled(cssCommonPadding, `
|
|
border-top: 1px solid ${colors.mediumGrey};
|
|
`);
|
|
|
|
const cssCommentEntry = styled('div', `
|
|
display: grid;
|
|
&-comment {
|
|
grid-template-columns: 1fr auto;
|
|
grid-template-rows: 1fr;
|
|
gap: 8px;
|
|
grid-template-areas: "text buttons";
|
|
}
|
|
&-start, &-reply {
|
|
grid-template-rows: 1fr auto;
|
|
grid-template-columns: 1fr;
|
|
gap: 8px;
|
|
grid-template-areas: "text" "buttons";
|
|
}
|
|
`);
|
|
|
|
const cssCommentEntryText = styled('div', `
|
|
grid-area: text;
|
|
`);
|
|
|
|
const cssCommentEntryButtons = styled('div', `
|
|
grid-area: buttons;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
|
|
gap: 8px;
|
|
`);
|
|
|
|
const cssTextArea = styled('textarea', `
|
|
min-height: 5em;
|
|
border-radius: 3px;
|
|
padding: 4px 6px;
|
|
border: 1px solid ${colors.darkGrey};
|
|
outline: none;
|
|
width: 100%;
|
|
resize: none;
|
|
max-height: 10em;
|
|
&-comment, &-reply {
|
|
min-height: 28px;
|
|
height: 28px;
|
|
}
|
|
`);
|
|
|
|
const cssHeaderBox = styled('div', `
|
|
background-color: ${colors.lightGrey};
|
|
padding: 12px; /* 12px * 2 + 24px (size of the icon) == 48px in height */
|
|
padding-right: 16px;
|
|
display: flex;
|
|
gap: 8px;
|
|
&-border {
|
|
border-bottom: 1px solid ${colors.mediumGrey};
|
|
}
|
|
`);
|
|
|
|
const cssTopic = styled('div', `
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
background-color: ${colors.light};
|
|
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2);
|
|
z-index: 100;
|
|
width: 325px;
|
|
overflow: hidden;
|
|
outline: none;
|
|
max-height: inherit;
|
|
&-disabled {
|
|
background-color: ${vars.labelActiveBg}
|
|
}
|
|
&-panel {
|
|
width: unset;
|
|
box-shadow: none;
|
|
border-radius: 0px;
|
|
background: unset;
|
|
border: 0px;
|
|
}
|
|
`);
|
|
|
|
const cssDiscussionWrapper = styled('div', `
|
|
border-bottom: 1px solid ${colors.darkGrey};
|
|
&:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.${cssTopic.className}-panel & {
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
background-color: ${colors.light};
|
|
margin-bottom: 4px;
|
|
}
|
|
`);
|
|
|
|
const cssDiscussion = styled('div', `
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 16px;
|
|
&-resolved {
|
|
background-color: ${vars.labelActiveBg};
|
|
cursor: pointer;
|
|
}
|
|
&-resolved * {
|
|
color: ${colors.slate} !important;
|
|
}
|
|
`);
|
|
|
|
const cssCommentPre = styled('pre', `
|
|
padding: 0px;
|
|
font-size: revert;
|
|
border: 0px;
|
|
background: inherit;
|
|
font-family: inherit;
|
|
margin: 0px;
|
|
white-space: break-spaces;
|
|
word-break: break-word;
|
|
word-wrap: break-word;
|
|
`);
|
|
|
|
const cssCommentList = styled('div', `
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: auto;
|
|
`);
|
|
|
|
const cssColumns = styled('div', `
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
`);
|
|
|
|
const cssCommentReplyWrapper = styled('div', `
|
|
margin-top: 16px;
|
|
`);
|
|
|
|
const cssComment = styled('div', `
|
|
border-bottom: 1px solid ${colors.mediumGrey};
|
|
.${cssCommentList.className} &:last-child {
|
|
border-bottom: 0px;
|
|
}
|
|
`);
|
|
|
|
const cssReplyList = styled('div', `
|
|
margin-left: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
`);
|
|
|
|
const cssCommentHeader = styled('div', `
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
overflow: hidden;
|
|
`);
|
|
|
|
const cssCommentBodyHeader = styled('div', `
|
|
display: flex;
|
|
align-items: baseline;
|
|
overflow: hidden;
|
|
`);
|
|
|
|
const cssIconButton = styled('div', `
|
|
flex: none;
|
|
margin: 0 4px 0 auto;
|
|
height: 24px;
|
|
width: 24px;
|
|
padding: 4px;
|
|
line-height: 0px;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
--icon-color: ${colors.slate};
|
|
&:hover, &.weasel-popup-open {
|
|
background-color: ${colors.darkGrey};
|
|
}
|
|
`);
|
|
|
|
const cssIconButtonMenu = styled('div', `
|
|
flex: none;
|
|
margin: 0 4px 0 auto;
|
|
height: 24px;
|
|
width: 24px;
|
|
padding: 4px;
|
|
line-height: 0px;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
--icon-color: ${colors.light};
|
|
&:hover, &.weasel-popup-open {
|
|
background-color: ${colors.darkGreen};
|
|
}
|
|
`);
|
|
|
|
const cssReplyButton = styled(textButton, `
|
|
align-self: flex-start;
|
|
display: flex;
|
|
gap: 4px;
|
|
margin-top: 16px;
|
|
`);
|
|
|
|
const cssTime = styled('div', `
|
|
color: ${colors.slate};
|
|
font-size: ${vars.smallFontSize};
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
letter-spacing: 0.02em;
|
|
line-height: 16px;
|
|
`);
|
|
|
|
const cssNick = styled('div', `
|
|
font-weight: 600;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
color: ${colors.darkText};
|
|
&-small {
|
|
font-size: 12px;
|
|
}
|
|
`);
|
|
|
|
const cssSpacer = styled('div', `
|
|
flex-grow: 1;
|
|
`);
|
|
|
|
const cssCloseButton = styled('div', `
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
line-height: 1px;
|
|
cursor: pointer;
|
|
--icon-color: ${colors.slate};
|
|
&:hover {
|
|
background-color: ${colors.mediumGreyOpaque};
|
|
}
|
|
`);
|
|
|
|
const cssHoverButton = styled(cssCloseButton, `
|
|
&:hover {
|
|
--icon-color: ${colors.lightGreen};
|
|
}
|
|
`);
|
|
|
|
// NOT IMPLEMENTED YET
|
|
// const cssRotate = styled(icon, `
|
|
// transform: rotate(180deg);
|
|
// `);
|
|
|
|
function domOnCustom(name: string, handler: (args: any, event: Event, element: Element) => void) {
|
|
return (el: Element) => {
|
|
dom.onElem(el, name, (ev, target) => {
|
|
const cv = ev as CustomEvent;
|
|
handler(cv.detail.args ?? {}, ev, target);
|
|
});
|
|
};
|
|
}
|
|
|
|
function trigger(element: Element, name: string, args?: any) {
|
|
element.dispatchEvent(new CustomEvent(name, {
|
|
bubbles: true,
|
|
detail: {args}
|
|
}));
|
|
}
|
|
|
|
const cssResolvedBlock = styled('div', `
|
|
margin-top: 5px;
|
|
--icon-color: ${colors.dark};
|
|
`);
|
|
|
|
const cssResolvedText = styled('span', `
|
|
color: ${colors.dark};
|
|
font-size: ${vars.smallFontSize};
|
|
margin-left: 5px;
|
|
`);
|
|
|
|
const cssTruncate = styled('div', `
|
|
position: absolute;
|
|
background: white;
|
|
inset: 0;
|
|
height: 2rem;
|
|
opacity: 57%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 600;
|
|
`);
|