import deepEqual from "fast-deep-equal"
import {baseKeymap} from "prosemirror-commands"
import {history, redo, undo} from "prosemirror-history"
import {keymap} from "prosemirror-keymap"
import {EditorState} from "prosemirror-state"
import {suggestionsPlugin, triggerCharacter} from "prosemirror-suggestions"
import {EditorView} from "prosemirror-view"
import {avatarTemplate, escapeText, findTarget} from "fwtoolkit"
import type {Editor} from "../../types.js"
import {notifyMentionedUser} from "./notify.js"
import {commentSchema} from "./schema.js"
export interface CommentMod {
editor: Editor
interactions: {
updateComment: (args: {
id: string
comment: any
isMajor: boolean
}) => string
deleteComment: (id: string) => void
deactivateAll: () => void
activeCommentId: string | boolean
editComment: boolean
updateDOM: () => void
editor: CommentEditor
cancelSubmit: () => void
collapseSelectionToEnd: () => void
}
}
interface CommentOptions {
isMajor?: boolean
isGlobal?: boolean
}
interface User {
id: number
name: string
username: string
}
interface SuggestionArgs {
range: {from: number; to: number}
text: string
}
export class CommentEditor {
mod: CommentMod
id: string
dom: HTMLElement
text: any
options: CommentOptions
isMajor: boolean | undefined
keepOpenAfterSubmit: boolean
selectedTag: number
userTaggerList: User[]
plugins: any[]
view!: EditorView
viewDOM!: HTMLElement
oldUserTags: number[]
tagRange: {from: number; to: number} | false
constructor(
mod: CommentMod,
id: string,
dom: HTMLElement,
text: any,
options: CommentOptions = {}
) {
this.mod = mod
this.id = id
this.dom = dom
this.text = text
this.options = options
this.isMajor = this.options.isMajor
this.keepOpenAfterSubmit = false
this.selectedTag = 0
this.userTaggerList = []
this.oldUserTags = []
this.tagRange = false
this.plugins = [
history(),
suggestionsPlugin({
escapeOnSelectionChange: true,
matcher: triggerCharacter("@"),
onEnter: (args: SuggestionArgs) => {
this.selectedTag = 0
this.tagRange = args.range
const search = args.text.slice(1)
if (search.length) {
this.setUserTaggerList(search)
this.showUserTagger()
}
return false
},
onChange: (args: SuggestionArgs) => {
this.selectedTag = 0
this.tagRange = args.range
const search = args.text.slice(1)
if (search.length) {
this.setUserTaggerList(search)
this.showUserTagger()
}
return false
},
onExit: (_args: SuggestionArgs) => {
this.selectedTag = 0
this.removeTagger()
return false
},
onKeyDown: ({event}: {event: KeyboardEvent}) => {
if (event.key === "ArrowDown") {
if (
this.userTaggerList.length >
this.selectedTag + 1
) {
this.selectedTag += 1
this.showUserTagger()
}
return true
} else if (event.key === "ArrowUp") {
if (this.selectedTag > 0) {
this.selectedTag -= 1
this.showUserTagger()
}
return true
} else if (event.key === "Enter") {
return this.selectUserTag()
}
return false
},
escapeKeys: ["Escape", "ArrowRight", "ArrowLeft"]
}),
keymap(baseKeymap),
keymap({
"Mod-z": undo,
"Mod-shift-z": undo,
"Mod-y": redo,
"Ctrl-Enter": () => {
this.submit()
return true
}
})
]
}
init(): void {
this.initViewDOM()
this.initView()
}
initViewDOM(): void {
this.viewDOM = document.createElement("div")
this.viewDOM.classList.add("ProseMirror-wrapper")
this.dom.appendChild(this.viewDOM)
this.dom.insertAdjacentHTML(
"beforeend",
`
`
)
}
initView(): void {
this.view = new EditorView(this.viewDOM, {
state: EditorState.create({
schema: commentSchema,
doc: commentSchema.nodeFromJSON({
type: "doc",
content: this.text
}),
plugins: this.plugins
}),
dispatchTransaction: tr => {
const newState = this.view.state.apply(tr)
this.view.updateState(newState)
this.updateButtons()
}
})
this.oldUserTags = this.getUserTags()
this.bind()
}
bind(): void {
this.dom.addEventListener("click", event => {
const el: {target?: HTMLElement} = {}
switch (true) {
case findTarget(event, "button.submit:not(.fw-disabled)", el):
{
const submittedId = this.submit()
if (this.keepOpenAfterSubmit) {
this.scrollToBottom()
} else if (this.options.isGlobal) {
if (submittedId) {
this.mod.interactions.deactivateAll()
this.mod.interactions.activeCommentId =
submittedId
this.mod.interactions.editComment = false
this.mod.interactions.updateDOM()
const answerEditor =
this.mod.interactions.editor
if (answerEditor?.view) {
try {
answerEditor.view.dom.focus({
preventScroll: true
})
} catch {
answerEditor.view.focus()
}
}
} else {
this.mod.interactions.activeCommentId = false
this.mod.interactions.deactivateAll()
this.mod.interactions.collapseSelectionToEnd()
}
} else {
this.mod.interactions.activeCommentId = false
this.mod.interactions.deactivateAll()
this.mod.interactions.collapseSelectionToEnd()
}
}
break
case findTarget(event, "button.cancel", el):
this.mod.interactions.cancelSubmit()
break
case findTarget(event, ".ProseMirror-wrapper", el):
this.view.focus()
break
case findTarget(event, ".tag-user", el):
this.selectedTag = Number.parseInt(
(el.target as HTMLElement).dataset.index || "0"
)
this.selectUserTag()
this.view.focus()
break
case findTarget(event, ".comment-is-major", el):
this.isMajor = !this.isMajor
this.updateButtons()
break
}
})
}
hasChanged(): boolean {
return (
!deepEqual(
this.text.length ? this.text : [{type: "paragraph"}],
this.view.state.doc.toJSON().content || [{type: "paragraph"}]
) || this.options.isMajor !== this.isMajor
)
}
updateButtons(): void {
const submitButton = this.dom.querySelector("button.submit")
if (this.hasChanged()) {
submitButton?.classList.remove("fw-disabled")
} else {
submitButton?.classList.add("fw-disabled")
}
}
submit(): string | false | void {
const comment = this.view.state.doc.toJSON().content
if (comment?.length > 0) {
const id = this.mod.interactions.updateComment({
id: this.id,
comment,
isMajor: this.isMajor || false
})
this.sendNotifications()
return id
} else {
this.mod.interactions.deleteComment(this.id)
return false
}
}
sendNotifications(): void {
const newUserTags = this.getUserTags().filter(
id => !this.oldUserTags.includes(id)
)
if (newUserTags.length) {
const comment = this.view.state.doc,
docId = this.mod.editor.docInfo.id as number
newUserTags.forEach(userId =>
notifyMentionedUser(
this.mod.editor.app.apiConnectors.document,
docId,
userId,
comment
)
)
}
}
setUserTaggerList(search: string): void {
const owner = this.mod.editor.docInfo.owner as unknown as User & {
contacts: User[]
}
this.userTaggerList = owner.contacts
.concat(owner)
.filter(
user =>
user.name.includes(search) ||
user.username.includes(search)
)
}
showUserTagger(): void {
if (!this.userTaggerList.length) {
return
}
this.dom.querySelector("div.tagger")!.innerHTML = this.userTaggerList
.map(
(user, index) =>
`
${user ? avatarTemplate({user}) : ''}
`
)
.join("")
}
selectUserTag(): boolean {
const user = this.userTaggerList[this.selectedTag]
if (!user || !this.tagRange) {
return false
}
const tr = this.view.state.tr.replaceRangeWith(
this.tagRange.from,
this.tagRange.to,
this.view.state.schema.nodes.collaborator.create({
id: user.id,
name: user.name
})
)
this.view.dispatch(tr)
return true
}
getUserTags(): number[] {
const users: number[] = []
this.view.state.doc.descendants(node => {
if (node.type.name === "collaborator") {
users.push(node.attrs.id)
}
})
return [...new Set(users)] // only unique values.
}
removeTagger(): void {
const tagger = this.dom.querySelector("div.tagger")
if (tagger) {
tagger.innerHTML = ""
}
this.tagRange = false
this.userTaggerList = []
}
scrollToBottom(): void {
const activeMarginBox = document.querySelector(
".margin-box.comment.fw-active .comment-answer-container"
)
if (activeMarginBox) {
;(activeMarginBox as HTMLElement).scrollTop =
activeMarginBox.scrollHeight
}
// scroll to bottom of the margin-box-container or global-comment-container
// when new comments are added while the compact layout (margin boxes
// as a bottom overlay) is active — signaled by #margin-box-filter
// switching to position:fixed, same convention the margin box
// placement code uses.
const filter = document.getElementById("margin-box-filter")
const compactLayout =
!!filter && getComputedStyle(filter).position === "fixed"
if (compactLayout) {
const activeMarginBoxContainer = document.querySelector(
"#margin-box-container, #global-comment-container"
)
if (activeMarginBoxContainer) {
;(activeMarginBoxContainer as HTMLElement).scrollTop =
activeMarginBoxContainer.scrollHeight
}
}
}
}