Inline comments plugin (#7473)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2024-12-16 20:22:34 +03:00 committed by GitHub
parent c496829576
commit 2158661df5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1476 additions and 28 deletions

View File

@ -1865,6 +1865,9 @@ dependencies:
telegram:
specifier: 2.22.2
version: 2.22.2
tippy.js:
specifier: ~6.3.7
version: 6.3.7
toposort:
specifier: ^2.0.2
version: 2.0.2
@ -22919,7 +22922,7 @@ packages:
dev: false
file:projects/chunter-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2):
resolution: {integrity: sha512-xn0DRxwtWoYCOZuUKT7v5zyeNjrU+lugmH/+rOqHdfVMoOmDg3l3cVWyAGf/4MOJMdT1dMt/0Nd3GT8R8Tiexg==, tarball: file:projects/chunter-resources.tgz}
resolution: {integrity: sha512-yjFks6novOHe2i9lKTi27FM5/AchzWFrTqCnp701jfGRYrSreAO+t2ebIlA/o0IGxKxHv4ZHG4fF6FIPitwwGw==, tarball: file:projects/chunter-resources.tgz}
id: file:projects/chunter-resources.tgz
name: '@rush-temp/chunter-resources'
version: 0.0.0
@ -32550,7 +32553,7 @@ packages:
dev: false
file:projects/text-editor-resources.tgz(@types/node@20.11.19)(bufferutil@4.0.8)(esbuild@0.20.1)(highlight.js@11.8.0)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2)(utf-8-validate@6.0.4):
resolution: {integrity: sha512-ZxHpsDpXsdc+KLgeibgCiwb/+isfY43XjNKXDtRfSSDy0X5kC4g5uBqAOri+Kk9v8Rko0jTKZtLLuo/AnqhJxw==, tarball: file:projects/text-editor-resources.tgz}
resolution: {integrity: sha512-hCYKIJCnXXMyGcVD33T1AMClfJ3pB7F0pDPTw8ti2g1ldib78IOMw334ZX4N2LhzVxtBe3AGbz0dpp6CHTQFXg==, tarball: file:projects/text-editor-resources.tgz}
id: file:projects/text-editor-resources.tgz
name: '@rush-temp/text-editor-resources'
version: 0.0.0
@ -32604,6 +32607,7 @@ packages:
svelte-eslint-parser: 0.33.1(svelte@4.2.19)
svelte-loader: 3.2.0(svelte@4.2.19)
svelte-preprocess: 5.1.3(postcss-load-config@4.0.2)(postcss@8.4.35)(sass@1.71.1)(svelte@4.2.19)(typescript@5.3.3)
tippy.js: 6.3.7
ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3)
typescript: 5.3.3
y-indexeddb: 9.0.12(yjs@13.6.19)

View File

@ -331,7 +331,16 @@ module.exports = [
hot: true,
client: {
logging: 'info',
overlay: true,
overlay: {
errors: true,
warnings: false,
runtimeErrors: (error) => {
if (error.message.includes("ResizeObserver")) {
return false;
}
return true;
},
},
progress: false
},
proxy:

View File

@ -361,4 +361,16 @@ export function createModel (builder: Builder): void {
category: 110,
index: 5
})
builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, {
action: textEditor.function.CreateInlineComment,
icon: textEditor.icon.Comment,
visibilityTester: textEditor.function.ShouldShowCreateInlineCommentAction,
isActive: {
name: 'inlineComment'
},
label: textEditor.string.Comment,
category: 110,
index: 10
})
}

View File

@ -34,6 +34,9 @@ export default mergeIds(textEditorId, textEditor, {
IsEditableTableActive: '' as Resource<TextActionVisibleFunction>,
IsEditableNote: '' as Resource<TextActionVisibleFunction>,
IsEditable: '' as Resource<TextActionVisibleFunction>,
IsHeadingVisible: '' as Resource<TextActionVisibleFunction>
IsHeadingVisible: '' as Resource<TextActionVisibleFunction>,
CreateInlineComment: '' as Resource<TextActionFunction>,
ShouldShowCreateInlineCommentAction: '' as Resource<TextActionVisibleFunction>
}
})

View File

@ -46,6 +46,7 @@
export let allowClose: boolean = true
export let embedded: boolean = false
export let useMaxWidth: boolean | undefined = undefined
export let sideContentSpace: number = 0
export let isFullSize: boolean = false
export let contentClasses: string | undefined = undefined
export let content: HTMLElement | undefined | null = undefined
@ -246,6 +247,8 @@
bind:this={content}
class={contentClasses ?? 'popupPanel-body__main-content py-8 clear-mins'}
class:max={useMaxWidth}
class:side-content-space={sideContentSpace > 0}
style:--side-content-space={`${sideContentSpace}px`}
>
<slot />
{#if !withoutActivity}
@ -275,6 +278,8 @@
<div
class={contentClasses ?? 'popupPanel-body__main-content py-8'}
class:max={useMaxWidth}
class:side-content-space={sideContentSpace > 0}
style:--side-content-space={`${sideContentSpace}px`}
use:resizeObserver={(element) => {
activityRef?.onContainerResized?.(element)
}}

View File

@ -397,6 +397,12 @@
width: calc(100% - 7.5rem);
max-width: 54rem;
&.side-content-space {
--side-content-space: 21rem;
max-width: calc(54rem + var(--side-content-space));
padding-right: var(--side-content-space);
}
&.max {
max-width: 100%;
}

View File

@ -477,6 +477,19 @@ pre.proseCodeBlock>pre.proseCode {
}
}
.proseInlineCommentHighlight {
background: rgba(255, 203, 0, .12);
border-bottom: 2px solid rgba(255, 203, 0, .35);
padding-bottom: 2px;
transition: background 0.2s ease, border 0.2s ease;
&.active {
transition-delay: 150ms;
background: rgba(255, 203, 0, .24);
border-bottom: 2px solid rgb(255, 203, 0);
}
}
.theme-dark {
@import './github-dark.scss';
}

View File

@ -97,12 +97,19 @@
if (clWidth === undefined) {
clWidth = tooltipHTML.clientWidth
}
let isElementInvalidTarget = false
if ($tooltip.element) {
rect = $tooltip.element.getBoundingClientRect()
rectAnchor = $tooltip.anchor
? $tooltip.anchor.getBoundingClientRect()
: $tooltip.element.getBoundingClientRect()
if (rect.x === 0 && rect.y === 0 && rect.width === 0 && rect.height === 0) {
isElementInvalidTarget = true
}
if ($tooltip.component) {
clearStyles()
if (rect.bottom + tooltipHTMLToCheck.clientHeight + 28 < docHeight) {
@ -175,8 +182,13 @@
options.transform = 'translate(-50%, -50%)'
options.classList = 'no-arrow'
}
options.visibility = 'visible'
shown = true
if (isElementInvalidTarget) {
options.visibility = 'hidden'
shown = false
} else {
options.visibility = 'visible'
shown = true
}
} else if (tooltipHTML) {
shown = false
options.visibility = 'hidden'
@ -368,6 +380,7 @@
style:height={options.height}
style:max-width={options.maxWidth}
style:transform={options.transform}
style:visibility={options.visibility}
style:z-index={($modals.findIndex((t) => t.type === 'tooltip') ?? 1) + 10000}
>
<span class="label">

View File

@ -34,9 +34,15 @@
const client = getClient()
let providedMenuActions: Action[] = []
let providedInlineActions: Action[] = []
let inlineActions: ViewAction[] = []
let isActionMenuOpened = false
$: providedMenuActions = actions.filter((a) => !a.inline)
$: providedInlineActions = actions.filter((a) => a.inline)
$: void updateInlineActions(message, excludedActions)
savedMessagesStore.subscribe(() => {
@ -58,7 +64,7 @@
Menu,
{
object: message,
actions,
actions: providedMenuActions,
baseMenuClass: activity.class.ActivityMessage,
excludedActions: inlineActions.map(({ _id }) => _id).concat(excludedActions)
},
@ -94,6 +100,17 @@
{#if message}
<div class="activityMessage-actionPopup">
{#each providedInlineActions as inline}
{#if inline.icon}
<ActivityMessageAction
label={inline.label}
size={'small'}
icon={inline.icon}
action={(ev) => inline.action({}, ev)}
/>
{/if}
{/each}
{#each inlineActions as inline}
{#if inline.icon}
<ActivityMessageAction

View File

@ -57,7 +57,7 @@
export let hoverable = true
export let pending = false
export let stale = false
export let hoverStyles: 'borderedHover' | 'filledHover' = 'borderedHover'
export let hoverStyles: 'borderedHover' | 'filledHover' | 'none' = 'borderedHover'
export let showDatePreposition = false
export let type: ActivityMessageViewType = 'default'
export let inlineActions: MessageInlineAction[] = []
@ -65,12 +65,13 @@
export let readonly: boolean = false
export let onClick: (() => void) | undefined = undefined
export let onReply: ((message: ActivityMessage) => void) | undefined = undefined
export let embeddedActions: boolean = false
export let socialIcon: Asset | undefined = undefined
const client = getClient()
let menuActionIds: string[] = []
let menuActions: ViewAction[] = []
let element: HTMLDivElement | undefined = undefined
let isActionsOpened = false
@ -83,7 +84,7 @@
$: withActions &&
getActions(client, message, activity.class.ActivityMessage).then((res) => {
menuActionIds = res.map(({ _id }) => _id)
menuActions = res
})
function scrollToMessage (): void {
@ -108,7 +109,7 @@
$: key = parentMessage != null ? `${message._id}_${parentMessage._id}` : message._id
$: isHidden = !!viewlet?.onlyWithParent && parentMessage === undefined
$: withActionMenu = withActions && !embedded && (actions.length > 0 || menuActionIds.length > 0)
$: withActionMenu = withActions && !embedded && (actions.findIndex((a) => !a.inline) >= 0 || menuActions.length > 0)
$: readonly = readonly || $restrictionStore.disableComments
@ -264,7 +265,13 @@
</div>
{#if withActions && !readonly}
<div class="actions" class:pending class:opened={isActionsOpened} class:isShort>
<div
class="actions"
class:embedded={embeddedActions}
class:pending
class:opened={isActionsOpened}
class:isShort
>
<ActivityMessageActions
message={isReactionMessage(message) ? parentMessage : message}
{actions}
@ -323,6 +330,11 @@
top: -0.75rem;
right: 0.75rem;
&.embedded {
top: 0.25rem;
right: 0.25rem;
}
&.opened:not(.pending) {
visibility: visible;
}

View File

@ -130,6 +130,7 @@
"StartConversation": "Start conversation",
"ViewingThreadFromArchivedChannel": "You are viewing a thread from an archived channel",
"ViewingArchivedChannel": "You are viewing an archived channel",
"OpenChatInSidebar": "Open chat in sidebar"
"OpenChatInSidebar": "Open chat in sidebar",
"ResolveThread": "Resolve"
}
}

View File

@ -130,6 +130,7 @@
"StartConversation": "Начать диалог",
"ViewingThreadFromArchivedChannel": "Вы просматриваете обсуждение из архивированного канала",
"ViewingArchivedChannel": "Вы просматриваете архивированный канал",
"OpenChatInSidebar": "Открыть чат в боковой панели"
"OpenChatInSidebar": "Открыть чат в боковой панели",
"ResolveThread": "Пометить завершенным"
}
}

View File

@ -63,6 +63,8 @@
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/workbench-resources": "^0.6.1",
"fast-equals": "^5.0.1",
"svelte": "^4.2.19"
"svelte": "^4.2.19",
"@hcengineering/text-editor-resources": "^0.6.0",
"@hcengineering/text-editor": "^0.6.0"
}
}

View File

@ -0,0 +1,152 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Person, PersonAccount } from '@hcengineering/contact'
import { personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import { getCurrentAccount, Markup, Ref } from '@hcengineering/core'
import { MessageViewer } from '@hcengineering/presentation'
import { Action, IconEdit, IconDelete, ShowMore } from '@hcengineering/ui'
import view from '@hcengineering/view'
import activity, { ActivityMessage, ActivityMessageViewType } from '@hcengineering/activity'
import { ActivityMessageTemplate } from '@hcengineering/activity-resources'
import { EmptyMarkup } from '@hcengineering/text'
import { ReferenceInput } from '@hcengineering/text-editor-resources'
export let value: any
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let withActions: boolean = true
export let showEmbedded = false
export let hideFooter = false
export let skipLabel = false
export let actions: Action[] = []
export let hoverable = true
export let hoverStyles: 'borderedHover' | 'filledHover' | 'none' = 'borderedHover'
export let withShowMore: boolean = true
export let hideLink = false
export let compact = false
export let readonly = false
export let type: ActivityMessageViewType = 'default'
export let onClick: (() => void) | undefined = undefined
export let onReply: ((message: ActivityMessage) => void) | undefined = undefined
export let handleSubmit: ((text: string, _id: string) => void) | undefined = undefined
const currentAccount = getCurrentAccount()
let account: PersonAccount | undefined = undefined
let person: Person | undefined = undefined
$: accountId = value?.createdBy
$: account = accountId !== undefined ? $personAccountByIdStore.get(accountId as Ref<PersonAccount>) : undefined
$: person = account?.person !== undefined ? $personByIdStore.get(account.person) : undefined
let isEditing = false
let additionalActions: Action[] = []
$: isOwn = account !== undefined && account._id === currentAccount._id
$: additionalActions = [
...(isOwn
? [
{
label: activity.string.Edit,
icon: IconEdit,
group: 'edit',
action: handleEditAction
},
{
label: view.string.Delete,
icon: IconDelete,
group: 'remove',
action: handleDeleteAction
}
]
: []),
...actions
]
let text: Markup = value?.message ?? EmptyMarkup
$: if (!isEditing) text = value?.message ?? EmptyMarkup
async function handleEditAction (): Promise<void> {
isEditing = true
}
async function handleDeleteAction (): Promise<void> {
if (value?._id) {
handleSubmit?.('', value._id)
}
}
async function handleSubmitEvent (event: CustomEvent<string>): Promise<void> {
if (value?._id) {
handleSubmit?.(event.detail, value._id)
}
isEditing = false
}
</script>
{#if value}
<ActivityMessageTemplate
message={value}
{person}
{showNotify}
{isHighlighted}
{isSelected}
{shouldScroll}
{embedded}
withActions={withActions && !isEditing}
actions={additionalActions}
{showEmbedded}
{hideFooter}
{hoverable}
{hoverStyles}
{skipLabel}
{readonly}
showDatePreposition={hideLink}
embeddedActions={true}
{type}
{onClick}
{onReply}
>
<svelte:fragment slot="content">
{#if !isEditing}
{#if withShowMore}
<ShowMore limit={compact ? 80 : undefined}>
<div class="clear-mins">
<MessageViewer message={text} />
</div>
</ShowMore>
{:else}
<div class="clear-mins">
<MessageViewer message={text} />
</div>
{/if}
{:else}
<ReferenceInput
showCancel={true}
content={text}
autofocus={true}
onCancel={() => {
isEditing = false
}}
on:message={handleSubmitEvent}
/>
{/if}
</svelte:fragment>
</ActivityMessageTemplate>
{/if}

View File

@ -0,0 +1,114 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import InlineCommentPresenter from './InlineCommentPresenter.svelte'
import textEditor from '@hcengineering/text-editor'
import { ReferenceInput } from '@hcengineering/text-editor-resources'
import { Action, Scroller, IconCheck, AnySvelteComponent } from '@hcengineering/ui'
import chunter from '../../plugin'
export let thread: any
export let autofocus: boolean
export let collapsed: boolean = false
export let width: number = 350
export let highlighted = false
export let handleSubmit: ((text: string, _id?: string) => void) | undefined = undefined
export let handleResolveThread: (() => void) | undefined = undefined
let headCommentActions: Action[] = []
$: headCommentActions =
handleResolveThread !== undefined
? [
{
label: chunter.string.ResolveThread,
icon: IconCheck as AnySvelteComponent,
group: 'inline-comment',
inline: true,
action: async () => {
handleResolveThread?.()
}
}
]
: []
async function handleSubmitEvent (event: CustomEvent<string>): Promise<void> {
handleSubmit?.(event.detail)
}
</script>
<div class="comment-thread" style:width={`${width}px`} class:collapsed class:highlighted>
<Scroller maxHeight={30} scrollDirection="vertical" disableOverscroll disablePointerEventsOnScroll>
{#each thread.messages as message, index (message._id)}
<InlineCommentPresenter
value={message}
skipLabel={false}
hoverStyles="none"
type="default"
withShowMore={true}
isHighlighted={false}
shouldScroll={false}
actions={index === 0 ? headCommentActions : []}
{handleSubmit}
/>
{/each}
</Scroller>
<div class="comment-input">
<ReferenceInput
kindSend="primary"
placeholder={textEditor.string.AddCommentPlaceholder}
noborder
{autofocus}
on:message={handleSubmitEvent}
/>
</div>
</div>
<style lang="scss">
.comment-thread {
--comment-thread-background-color: var(--theme-comp-header-color);
--comment-thread-border: 1px solid var(--theme-button-border);
--comment-thread-box-shadow: var(--button-shadow);
position: relative;
max-width: 100%;
background-color: var(--comment-thread-background-color);
border-radius: 0.5rem;
box-shadow: var(--comment-thread-box-shadow);
z-index: 1;
transition-property: transform, background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.comment-thread.collapsed {
background-color: transparent;
border: var(--comment-thread-border);
box-shadow: none;
transform: translateX(1rem);
&.highlighted {
transform: translateX(0.5rem);
background-color: var(--comment-thread-background-color);
transition-delay: 150ms;
}
}
.comment-thread.collapsed .comment-input {
display: none;
}
</style>

View File

@ -55,6 +55,7 @@ import ChatWidgetTab from './components/ChatWidgetTab.svelte'
import WorkbenchTabExtension from './components/WorkbenchTabExtension.svelte'
import DirectMessageButton from './components/DirectMessageButton.svelte'
import EmployeePresenter from './components/ChunterEmployeePresenter.svelte'
import InlineCommentThread from './components/inline-comment/InlineCommentThread.svelte'
import {
chunterSpaceLinkFragmentProvider,
@ -186,7 +187,8 @@ export default async (): Promise<Resources> => ({
ChatWidgetTab,
WorkbenchTabExtension,
DirectMessageButton,
EmployeePresenter
EmployeePresenter,
InlineCommentThread
},
activity: {
ChannelCreatedMessage,

View File

@ -108,6 +108,7 @@ export default mergeIds(chunterId, chunter, {
ArchiveActivityConfirmationMessage: '' as IntlString,
JoinChannelHeader: '' as IntlString,
JoinChannelText: '' as IntlString,
LatestMessages: '' as IntlString
LatestMessages: '' as IntlString,
ResolveThread: '' as IntlString
}
})

View File

@ -149,7 +149,8 @@ export default plugin(chunterId, {
ThreadMessagePresenter: '' as AnyComponent,
ChatMessagePreview: '' as AnyComponent,
ThreadMessagePreview: '' as AnyComponent,
DirectIcon: '' as AnyComponent
DirectIcon: '' as AnyComponent,
InlineCommentThread: '' as AnyComponent
},
activity: {
MembersChangedMessage: '' as AnyComponent

View File

@ -271,6 +271,7 @@
editorAttributes={{ style: 'padding: 0 2em; margin: 0 -2em;' }}
overflow="none"
canShowPopups={!$areDocumentCommentPopupsOpened}
enableInlineComments={false}
onExtensions={handleExtensions}
kitOptions={{
note: {

View File

@ -32,6 +32,7 @@
export let focusIndex = -1
export let overflow: 'auto' | 'none' = 'none'
export let editorAttributes: Record<string, string> = {}
export let requestSideSpace: ((width: number) => void) | undefined = undefined
const client = getClient()
@ -74,6 +75,7 @@
{boundary}
{overflow}
{editorAttributes}
{requestSideSpace}
onExtensions={handleExtensions}
on:update
on:open-document

View File

@ -197,6 +197,12 @@
localStorage.setItem('document.useMaxWidth', useMaxWidth.toString())
}
let sideContentSpace = 0
function updateSizeContentSpace (width: number): void {
sideContentSpace = width
}
onMount(() => {
dispatch('open', { ignoreKeys: ['comments', 'name'] })
})
@ -259,6 +265,7 @@
isCustomAttr={false}
isSub={false}
bind:useMaxWidth
{sideContentSpace}
printHeader={false}
{embedded}
adaptive={'default'}
@ -369,6 +376,7 @@
boundary={content}
overflow={'none'}
editorAttributes={{ style: 'padding: 0 2em 2em; margin: 0 -2em; min-height: 30vh' }}
requestSideSpace={updateSizeContentSpace}
attachFile={async (file) => {
return await createEmbedding(file)
}}

View File

@ -197,4 +197,8 @@
<path d="M9.5,17h13c0.6,0,1-0.4,1-1s-0.4-1-1-1h-13c-0.6,0-1,0.4-1,1S8.9,17,9.5,17z" />
<path d="M15.1,20.6H9.5c-0.6,0-1,0.4-1,1s0.4,1,1,1h5.6c0.6,0,1-0.4,1-1S15.6,20.6,15.1,20.6z" />
</symbol>
<symbol id="comment" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.4,25.4L27.4,25.4c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.2,0.5-0.3c0.1-0.1,0.2-0.3,0.3-0.5C29,24.2,29,24,29,23.9v-0.1c0-0.1,0-0.2-0.1-0.4l-1-3.5c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1l0,0c1.2-1.8,1.9-4,1.9-6.2c0-3-1.2-5.8-3.3-7.9C24.4,3.3,21.4,2,18.1,2c-2.7,0-5.4,0.9-7.5,2.6c-2.1,1.7-3.5,4-4.1,6.5c-0.2,0.8-0.3,1.6-0.3,2.4c0,1.5,0.3,3,0.9,4.4c0.6,1.4,1.4,2.7,2.5,3.7c2.2,2.2,5.2,3.4,8.3,3.4c1.2,0,2.8-0.4,3.2-0.5c0.8-0.2,1.6-0.5,1.7-0.5c0.1,0,0.2,0,0.2,0c0.1,0,0.2,0,0.2,0l0,0l3.5,1.3C27.1,25.3,27.2,25.3,27.4,25.4L27.4,25.4z M26.7,23l-2.7-1h0c-0.6-0.2-1.3-0.3-1.9,0c0,0,0,0,0,0c-0.2,0.1-0.8,0.3-1.5,0.5C19.9,22.8,18.7,23,18,23c-5.3,0-9.7-4.3-9.7-9.6c0-0.6,0.1-1.3,0.2-1.9c0.9-4.4,5-7.5,9.7-7.5c2.7,0,5.3,1,7.1,2.9C27,8.7,28,11,28,13.4c0,1.8-0.5,3.6-1.5,5.1c-0.1,0.1-0.1,0.2-0.2,0.3l0,0c-0.3,0.6-0.4,1.1-0.3,1.5L26.7,23z" />
<path d="M3.6,29.7C3.8,29.9,4.2,30,4.5,30h0c0.2,0,0.4,0,0.6-0.1l3.5-1.4l0,0c1.4,0.5,2.8,0.8,4.2,0.8h0c1.9,0,3.8-0.5,5.5-1.5c0.1-0.1,0.2-0.2,0.3-0.3c0.1-0.1,0.1-0.2,0.2-0.4s0-0.3,0-0.4c0-0.1-0.1-0.3-0.1-0.4c-0.1-0.1-0.2-0.2-0.3-0.3c-0.1-0.1-0.2-0.1-0.4-0.2c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0.1-0.4,0.1c-1.4,0.8-2.9,1.2-4.5,1.2h0c-1.1,0-2.3-0.2-3.4-0.7c-0.5-0.2-1-0.2-1.5,0l-2.7,1c0.3-1.3,0.5-2.8,0.6-2.9c0.1-0.5-0.1-0.9-0.3-1.3l0,0c-0.8-1.2-1.3-2.7-1.4-4.2c-0.1-1.5,0.3-3,1-4.3c0.1-0.2,0.2-0.5,0.1-0.8C5,14,4.9,13.8,4.6,13.6c-0.2-0.1-0.5-0.2-0.8-0.1c-0.3,0.1-0.5,0.2-0.6,0.5c-0.9,1.6-1.3,3.5-1.3,5.4c0.1,1.9,0.7,3.7,1.7,5.2v0c-0.1,0.5-0.3,1.7-0.5,2.7C3.1,27.7,3.1,28,3,28.2C3,28.5,3,28.8,3.1,29C3.2,29.3,3.3,29.5,3.6,29.7L3.6,29.7z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -59,6 +59,9 @@
"SeparatorLine": "Separator line",
"TodoList": "Action item",
"DrawingBoard": "Drawing board",
"MermaidDiargram": "Diagram"
"MermaidDiargram": "Diagram",
"Comment": "Comment",
"AddComment": "Add a comment",
"AddCommentPlaceholder": "Add a comment..."
}
}

View File

@ -59,6 +59,9 @@
"SeparatorLine": "Разделительная линия",
"TodoList": "Действие",
"DrawingBoard": "Доска",
"MermaidDiargram": "Диаграмма"
"MermaidDiargram": "Диаграмма",
"Comment": "Комментарий",
"AddComment": "Добавить комментарий",
"AddCommentPlaceholder": "Добавьте комментарий..."
}
}

View File

@ -39,5 +39,6 @@ loadMetadata(textEditor.icon, {
Expand: `${icons}#expand`,
ScaleOut: `${icons}#scaleOut`,
Download: `${icons}#download`,
Note: `${icons}#note`
Note: `${icons}#note`,
Comment: `${icons}#comment`
})

View File

@ -85,6 +85,8 @@
"y-indexeddb": "^9.0.12",
"lowlight": "^3.1.0",
"mermaid": "~11.4.1",
"@hcengineering/theme": "^0.6.5"
"@hcengineering/theme": "^0.6.5",
"tippy.js": "~6.3.7",
"@hcengineering/chunter": "^0.6.20"
}
}

View File

@ -84,6 +84,7 @@
import { type FileAttachFunction } from './extension/types'
import { completionConfig, inlineCommandsConfig } from './extensions'
import { MermaidExtension, mermaidOptions } from './extension/mermaid'
import { InlineCommentExtension } from './extension/inlineComment'
export let object: Doc
export let attribute: KeyedAttribute
@ -112,6 +113,8 @@
export let withSideMenu = true
export let withInlineCommands = true
export let kitOptions: Partial<EditorKitOptions> = {}
export let requestSideSpace: ((width: number) => void) | undefined = undefined
export let enableInlineComments: boolean = true
const client = getClient()
const dispatch = createEventDispatcher()
@ -148,6 +151,7 @@
let element: HTMLElement
let textToolbarElement: HTMLElement
let imageToolbarElement: HTMLElement
let editorPopupContainer: HTMLElement
let placeHolderStr: string = ''
@ -430,6 +434,12 @@
onMount(async () => {
await ph
if (enableInlineComments) {
optionalExtensions.push(
InlineCommentExtension.configure({ ydoc, boundary, popupContainer: editorPopupContainer, requestSideSpace })
)
}
editor = new Editor({
enableContentCheck: true,
element,
@ -534,6 +544,7 @@
style="display: none"
on:change={fileSelected}
/>
<div class="editorPopupContainer" bind:this={editorPopupContainer}></div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div

View File

@ -45,6 +45,8 @@
export let attachFile: FileAttachFunction | undefined = undefined
export let canShowPopups = true
export let kitOptions: Partial<EditorKitOptions> = {}
export let requestSideSpace: ((width: number) => void) | undefined = undefined
export let enableInlineComments: boolean = true
let element: HTMLElement
@ -99,6 +101,8 @@
{canShowPopups}
{editorAttributes}
{kitOptions}
{requestSideSpace}
{enableInlineComments}
on:editor
on:update
on:open-document

View File

@ -23,7 +23,8 @@
handler,
registerFocus,
deviceOptionsStore as deviceInfo,
checkAdaptiveMatching
checkAdaptiveMatching,
IconClose
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { FocusPosition } from '@tiptap/core'
@ -36,25 +37,31 @@
import { completionConfig } from './extensions'
import { EmojiExtension } from './extension/emoji'
import { IsEmptyContentExtension } from './extension/isEmptyContent'
import view from '@hcengineering/view'
import Send from './icons/Send.svelte'
export let content: Markup = EmptyMarkup
export let showHeader = false
export let showActions = true
export let showSend = true
export let showCancel = false
export let iconSend: Asset | AnySvelteComponent | undefined = undefined
export let labelSend: IntlString | undefined = undefined
export let labelCancel: IntlString | undefined = undefined
export let kindSend: ButtonKind = 'ghost'
export let kindCancel: ButtonKind = 'ghost'
export let haveAttachment = false
export let placeholder: IntlString | undefined = undefined
export let extraActions: RefAction[] = []
export let loading: boolean = false
export let focusable: boolean = false
export let noborder: boolean = false
export let boundary: HTMLElement | undefined = undefined
export let autofocus: FocusPosition = false
export let canEmbedFiles = true
export let canEmbedImages = true
export let onPaste: ((view: EditorView, event: ClipboardEvent) => boolean) | undefined = undefined
export let onCancel: (() => void) | undefined = undefined
const dispatch = createEventDispatcher()
const buttonSize = 'medium'
@ -142,7 +149,7 @@
})
</script>
<div class="ref-container" class:focusable>
<div class="ref-container" class:focusable class:noborder>
{#if showHeader && $$slots.header}
<div class="header">
<slot name="header" />
@ -160,7 +167,7 @@
if (canSubmit) {
dispatch('message', ev.detail)
content = EmptyMarkup
editor?.clear()
editor?.clear?.()
}
}}
on:blur={() => {
@ -207,7 +214,34 @@
{/if}
</div>
{#if showSend}
{#if showCancel && showSend}
<div class="buttons-group {shrinkButtons ? 'xxsmall-gap' : 'xsmall-gap'}">
<Button
{loading}
icon={IconClose}
iconProps={{ size: buttonSize }}
kind={kindCancel}
size={buttonSize}
showTooltip={{
label: labelCancel ?? view.string.Cancel
}}
on:click={onCancel}
/>
<Button
{loading}
disabled={!canSubmit}
icon={iconSend ?? Send}
iconProps={{ size: buttonSize }}
kind={kindSend}
size={buttonSize}
showTooltip={{
label: labelSend ?? textEditor.string.Send
}}
on:click={submit}
/>
</div>
{/if}
{#if showSend && !showCancel}
<Button
{loading}
disabled={!canSubmit}
@ -238,6 +272,10 @@
border-color: var(--primary-edit-border-color);
}
}
&.noborder {
border: none;
}
}
.header {

View File

@ -0,0 +1,995 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getResource } from '@hcengineering/platform'
import { type Editor, Mark } from '@tiptap/core'
import {
type EditorState,
Plugin,
PluginKey,
type PluginView,
type Selection,
TextSelection,
type Transaction
} from '@tiptap/pm/state'
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
import { SvelteRenderer } from '../node-view'
import type { AnySvelteComponent } from '@hcengineering/ui'
import { Fragment, Slice, type Node } from '@tiptap/pm/model'
import { type Account, type Markup, type Ref, type Timestamp, getCurrentAccount, generateId } from '@hcengineering/core'
import tippy, { type Instance } from 'tippy.js'
import 'tippy.js/animations/shift-toward.css'
import { type Doc as YDoc, type Map as YMap } from 'yjs'
import core from '@hcengineering/core'
import { type ActionContext } from '@hcengineering/presentation'
import chunter from '@hcengineering/chunter'
interface InlineCommentExtensionOptions {
boundary?: HTMLElement
popupContainer?: HTMLElement
commentWidth?: number
ydoc: YDoc
ydocExtensionField?: string
minEditorWidth?: number
requestSideSpace?: (width: number) => void
}
type InlineCommentDisplayMode = 'compact' | 'full'
interface PointerState {
hover: Set<string>
focus: Set<string>
}
interface MetaPatch {
newCommentRequested?: boolean
displayModeConstraint?: InlineCommentDisplayMode
component?: AnySvelteComponent
pointer?: Partial<PointerState>
threads?: Map<string, Thread>
}
function getMeta (tx?: Transaction): MetaPatch | undefined {
return tx?.getMeta('inline-comment')
}
function setMeta (tx: Transaction, meta: MetaPatch): Transaction {
return tx.setMeta('inline-comment', meta).setMeta('addToHistory', false)
}
function getYDocCommentMap (options: InlineCommentExtensionOptions): YMap<InlineComment> {
return options.ydoc.getMap(options.ydocExtensionField ?? 'inline-comments')
}
const commentWidth = 320
const commentGap = 16
const decoratedCommentClass = 'proseInlineCommentHighlight'
interface InlineComment {
_id: string
_class: string
thread: string
message: Markup
createdOn: Timestamp
createdBy: Ref<Account>
editedOn?: Timestamp
}
interface Thread {
_id: string
_class: string
messages: InlineComment[]
}
interface ThreadPresenterProps {
thread: Thread
autofocus?: boolean
collapsed?: boolean
width?: number
highlighted?: boolean
handleSubmit?: (text: string, _id?: string) => void
handleResolveThread?: (() => void) | undefined
}
const extensionName = 'inline-comment'
export const InlineCommentExtension = Mark.create<InlineCommentExtensionOptions>({
name: 'inline-comment',
excludes: '',
inclusive: false,
parseHTML () {
return [
{
tag: 'span.proseInlineComment[data-inline-comment-thread]'
}
]
},
renderHTML ({ HTMLAttributes, mark }) {
return ['span', { ...HTMLAttributes, class: 'proseInlineComment' }, 0]
},
addAttributes () {
const name = 'data-inline-comment-thread-id'
return {
thread: {
default: undefined,
parseHTML: (element) => {
return element.getAttribute(name)
},
renderHTML: (attributes) => {
return { [name]: attributes.thread }
}
}
}
},
addProseMirrorPlugins () {
return [...(this.parent?.() ?? []), InlineCommentDecorator(this.options)]
}
})
interface InlineCommentDecoratorState {
commentThreads: Map<string, Thread>
commentViews: Map<string, InlineCommentView>
decorationSet: DecorationSet
component?: AnySvelteComponent
displayMode: InlineCommentDisplayMode
displayModeConstraint: InlineCommentDisplayMode
pointer: PointerState
pendingComment?: { thread: Thread, selection: Selection }
}
const inlineCommentDecoratorKey = new PluginKey('inline-comment-decorator')
function getCommentDecoratorState (editorState: EditorState): InlineCommentDecoratorState | undefined {
return inlineCommentDecoratorKey.getState(editorState)
}
export function InlineCommentDecorator (options: InlineCommentExtensionOptions): Plugin {
return new Plugin<InlineCommentDecoratorState>({
key: inlineCommentDecoratorKey,
props: {
decorations (state) {
return this.getState(state)?.decorationSet
},
handleDOMEvents: {
mousemove: handleInlineCommentMouseHover
},
transformPasted: (slice) => {
const nodes: Node[] = []
slice.content.forEach((node) => {
nodes.push(removeMarkFromNode(node, 'inline-comment'))
})
return new Slice(Fragment.fromArray(nodes), slice.openStart, slice.openEnd)
}
},
state: {
init (config, state) {
const decoratorState: InlineCommentDecoratorState = {
commentThreads: new Map(),
decorationSet: DecorationSet.empty,
displayMode: 'compact',
displayModeConstraint: 'compact',
commentViews: new Map(),
pointer: {
hover: new Set(),
focus: new Set()
}
}
return buildCommentDecoratorState(options, decoratorState, state, state)
},
apply (tr, prev, oldState, newState) {
return buildCommentDecoratorState(options, prev, oldState, newState, tr)
}
},
view (view) {
return initCommentDecoratorView(options, view)
}
})
}
function initCommentDecoratorView (options: InlineCommentExtensionOptions, view: EditorView): PluginView {
const destructors = [
() => {
getCommentDecoratorState(view.state)?.commentViews?.forEach((view) => {
view.destroy()
})
}
]
const fetchComponent = async (): Promise<void> => {
// In the current package structure, it is practically impossible to import anything from here
// without introducing cyclic dependencies, since text-editor-resources is in the dependency
// hierarchy of almost all other packages. So any complex Svelte components have to be moved to other packages.
// In the future, it might be worth considering putting text editor plugins in the text-editor-plugins
// package so that one can create dependencies on packages such as activity and chunter.
const component = await getResource(chunter.component.InlineCommentThread)
view.dispatch(setMeta(view.state.tr, { component }))
}
void fetchComponent()
const elementSize = {
editor: 0,
boundary: 0
}
const updateSize = (key: keyof typeof elementSize, w: number): void => {
const prev = { ...elementSize }
elementSize[key] = w
const minEditorW = options.minEditorWidth ?? 62 * 16
const state = getCommentDecoratorState(view.state)
const canFitSideView = elementSize.boundary - Math.max(elementSize.editor, minEditorW) > commentWidth
const displayModeNew = canFitSideView ? 'full' : 'compact'
const displayModeOld = state?.displayMode ?? 'compact'
if (displayModeNew !== displayModeOld) {
view.dispatch(setMeta(view.state.tr, { displayModeConstraint: displayModeNew }))
}
if (prev.editor !== elementSize.editor && state?.commentViews !== undefined) {
for (const view of state.commentViews.values()) view.tippynode?.setProps({ duration: 0 })
}
}
if (options.boundary !== undefined && options.requestSideSpace !== undefined) {
updateSize('editor', view.dom.offsetWidth)
updateSize('boundary', options.boundary.offsetWidth)
destructors.push(
observeSizeChanges(view.dom, (w, h) => {
updateSize('editor', w)
}),
observeSizeChanges(options.boundary, (w, h) => {
updateSize('boundary', w)
})
)
}
const commentMap = getYDocCommentMap(options)
const commentMapObserver = (): void => {
const threads = fetchCommentThreads(options)
view.dispatch(setMeta(view.state.tr, { threads }))
}
commentMap.observe(commentMapObserver)
destructors.push(() => {
commentMap.unobserve(commentMapObserver)
})
commentMapObserver()
return {
destroy () {
for (const fn of destructors) fn()
}
}
}
function buildCommentDecoratorState (
options: InlineCommentExtensionOptions,
prev: InlineCommentDecoratorState,
oldState: EditorState,
newState: EditorState,
tr?: Transaction
): InlineCommentDecoratorState {
const meta = getMeta(tr)
const isNewCommentRequested = meta?.newCommentRequested ?? false
const isDocChanged = tr?.docChanged ?? true
const isSelectionChanged = !oldState.selection.eq(newState.selection)
const isUpdateRequested = meta !== undefined
const pointer: PointerState = {
focus:
meta?.pointer?.focus !== undefined
? meta.pointer.focus
: !isSelectionChanged
? prev.pointer.focus
: new Set<string>(),
hover: meta?.pointer?.hover !== undefined ? meta.pointer.hover : prev.pointer.hover
}
let pendingComment: InlineCommentDecoratorState['pendingComment'] = isNewCommentRequested
? {
thread: { _id: generateId(), _class: core.class.Obj, messages: [] },
selection: newState.selection
}
: prev.pendingComment !== undefined && !isSelectionChanged
? {
thread: prev.pendingComment.thread,
selection: mapSelection(prev.pendingComment.selection)
}
: undefined
const commentThreads = meta?.threads ?? prev.commentThreads
const commentViews = new Map(prev.commentViews)
const component = meta?.component ?? prev.component
const visitedCommentViews: InlineCommentView[] = []
const settledCommentViews = Array.from(commentViews.values())
.filter((v) => v.props.thread._id !== pendingComment?.thread?._id)
.filter((v) => commentThreads.has(v.props.thread._id))
const displayModeConstraint = meta?.displayModeConstraint ?? prev.displayModeConstraint
const displayMode = displayModeConstraint === 'full' && settledCommentViews.length > 0 ? 'full' : 'compact'
let decorationSet = mapDecorationSet(prev.decorationSet)
const initCommentView = (viewState: InlineCommentViewProps): InlineCommentView => {
const view = commentViews.get(viewState.thread._id) ?? new InlineCommentView(options, viewState)
commentViews.set(viewState.thread._id, view)
visitedCommentViews.push(view)
return view
}
const buildCommentDecorations = (thread: Thread, from: number, to: number, mark?: NodeMark): Decoration[] => {
const siblings = Array.from(visitedCommentViews)
const isSelected = isSelectionContainedByRange(newState.selection, from, to)
const isFocused = pointer.focus.has(thread._id)
const isHovered = pointer.hover.has(thread._id)
const isActive = isSelected || isFocused
const isPendingComment = thread._id === pendingComment?.thread._id
const isCollapsed = displayMode === 'compact' ? false : !isActive
const isVisible = displayMode === 'full' || isActive
const isHighligted = isActive || isHovered || isPendingComment
const commentViewProps: InlineCommentViewProps = {
mark,
siblings,
thread,
range: { from, to },
component,
showPopup: isVisible,
boundary: options.boundary,
popupContainer: options.popupContainer,
autofocus: isPendingComment,
displayMode,
highlighted: isHighligted,
collapsed: isCollapsed
}
const commentView = initCommentView(commentViewProps)
const commentSpanCSSClass = isHighligted ? `${decoratedCommentClass} active` : `${decoratedCommentClass}`
const decorations = [
Decoration.widget(to, (view) => {
return commentView.build(view, commentViewProps)
}),
Decoration.inline(from, to, { class: commentSpanCSSClass })
]
return decorations
}
if (displayMode !== prev.displayMode) {
options.requestSideSpace?.(displayMode === 'compact' ? 0 : commentWidth)
}
if (isDocChanged || isSelectionChanged || isUpdateRequested) {
const decorations: Decoration[] = []
const ranges = new Map<string, { thread: Thread, from: number, to: number, mark?: NodeMark }>()
newState.doc.descendants((node, pos) => {
const marks = getThreadsFromNode(commentThreads, node)
for (const m of marks) {
const thread = m.thread
const part = { from: pos, to: pos + node.nodeSize }
const range = ranges.get(thread._id) ?? { thread, from: part.from, to: part.to, mark: m.mark }
if (part.from < range.from) range.from = part.from
if (part.to > range.to) range.to = part.to
ranges.set(thread._id, range)
}
})
if (pendingComment !== undefined && ranges.has(pendingComment.thread._id)) {
pendingComment = undefined
}
if (pendingComment !== undefined) {
const selection = pendingComment.selection
const range = {
thread: pendingComment.thread,
from: selection.from,
to: selection.to
}
ranges.set(pendingComment.thread._id, range)
}
const orderedRanges = Array.from(ranges.values()).sort((a, b) => a.from - b.from)
for (const range of orderedRanges) {
decorations.push(...buildCommentDecorations(range.thread, range.from, range.to, range.mark))
}
decorationSet = DecorationSet.create(newState.doc, decorations)
const unusedCommentViews = new Set(commentViews.keys())
for (const view of visitedCommentViews) {
unusedCommentViews.delete(view.props.thread._id)
}
for (const id of unusedCommentViews) {
commentViews.get(id)?.destroy()
commentViews.delete(id)
}
}
return {
commentThreads,
displayMode,
displayModeConstraint,
decorationSet,
component,
commentViews,
pendingComment,
pointer
}
}
function handleInlineCommentMouseHover (view: EditorView, event: MouseEvent): void {
const element = event.target as HTMLElement
const isValidHoverTarget = element?.classList?.contains(decoratedCommentClass)
const state = getCommentDecoratorState(view.state)
if (state === undefined) return
const threadIds = new Set<string>()
if (isValidHoverTarget) {
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (pos === null) return
const { doc } = view.state
const rpos = doc.resolve(pos.pos)
const node = rpos.nodeAfter
if (node === null) return
const marks = getThreadsFromNode(state.commentThreads, node)
if (marks.length < 1) return
for (const m of marks) {
threadIds.add(m.thread._id)
}
}
updatePointerState(view, { hover: threadIds })
}
function removeMarkFromNode (node: Node, name: string): Node {
if (node.isText) {
return node.mark(node.marks.filter((mark) => mark.type.name !== name))
}
if (node.content.size > 0) {
const nodes: Node[] = []
node.content.forEach((child) => {
nodes.push(removeMarkFromNode(child, name))
})
return node.copy(Fragment.fromArray(nodes))
}
return node
}
interface InlineCommentViewProps {
siblings: InlineCommentView[]
thread: Thread
range: { from: number, to: number }
mark?: NodeMark
showPopup: boolean
component?: AnySvelteComponent
boundary?: HTMLElement
popupContainer?: HTMLElement
autofocus?: boolean
collapsed?: boolean
highlighted?: boolean
displayMode: InlineCommentDisplayMode
}
interface OffsetRect {
x: number
y: number
width: number
height: number
}
class InlineCommentView {
pluginOptions: InlineCommentExtensionOptions
props: InlineCommentViewProps
container: HTMLElement | null
renderer: SvelteRenderer | null
tippynode: Instance | null
rect: OffsetRect
destructors: Array<() => void>
handlers: {
handleResolveThread: () => void
handleSubmit: (text: string, _id?: string) => void
} | null
constructor (pluginOptions: InlineCommentExtensionOptions, props: InlineCommentViewProps) {
this.pluginOptions = pluginOptions
this.props = props
this.container = null
this.renderer = null
this.tippynode = null
this.destructors = []
this.handlers = null
this.rect = {
x: 0,
y: 0,
width: 0,
height: 0
}
}
buildComponentProps (view: EditorView): ThreadPresenterProps {
return {
thread: this.props.thread,
autofocus: this.props.autofocus ?? false,
width: commentWidth,
highlighted: this.props.highlighted,
collapsed: this.props.collapsed ?? false,
handleResolveThread: this.handlers?.handleResolveThread,
handleSubmit: this.handlers?.handleSubmit
}
}
build (view: EditorView, props?: InlineCommentViewProps): HTMLElement {
const prevprops = this.props
if (props !== undefined) this.props = props
if (this.handlers === null) {
this.handlers = {
handleResolveThread: () => {
resolveThread(this.pluginOptions, view, this.props.thread._id)
},
handleSubmit: (text: string, _id?: string) => {
updateThreadComment(this.pluginOptions, view, { _id, thread: this.props.thread._id, message: text })
}
}
}
const displayMode = this.props.displayMode
const placement = displayMode === 'compact' ? 'bottom' : 'right-start'
const getReferenceClientRect = (): DOMRect => {
return displayMode === 'full'
? getReferenceAnchor(view, this.props.range.from, this.props.range.to)
: getReferenceRect(view, this.props.range.from, this.props.range.to)
}
if (this.tippynode === null && this.props.component !== undefined) {
this.container = document.createElement('div')
const listeners: Array<[keyof HTMLElementEventMap, () => void]> = [
[
'click',
(): void => {
updatePointerState(view, { focus: new Set([this.props.thread._id]) })
}
],
[
'mouseenter',
(): void => {
updatePointerState(view, { hover: new Set([this.props.thread._id]) })
}
],
[
'mouseleave',
(): void => {
updatePointerState(view, { hover: new Set([]) })
}
]
]
for (const listener of listeners) {
this.container.addEventListener(listener[0], listener[1])
this.destructors.push(() => this.container?.removeEventListener(listener[0], listener[1]))
}
this.destructors.push(
observeSizeChanges(this.container, () => {
forceUpdateDecorations(view)
})
)
this.tippynode = tippy(view.dom, {
duration: 100,
getReferenceClientRect,
animation: 'shift-toward',
inertia: true,
content: this.container,
maxWidth: commentWidth,
interactive: true,
trigger: 'manual',
placement,
hideOnClick: 'toggle',
onDestroy: () => {
this.renderer?.destroy()
},
appendTo: () => this.props.popupContainer ?? document.body,
zIndex: 10000,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: this.props.boundary ?? view.dom,
padding: commentGap,
altAxis: true
}
},
{
name: 'collectBoxMetrics',
phase: 'read',
enabled: true,
fn: ({ state }) => {
const offset = state.modifiersData.popperOffsets
if (offset === undefined) return
this.rect = {
x: offset.x,
y: offset.y,
width: state.elements.popper.offsetWidth,
height: state.elements.popper.offsetHeight
}
}
},
{
name: 'adjustSideViewOffsets',
phase: 'main',
enabled: true,
requires: ['preventOverflow', 'collectBoxMetrics'],
fn: ({ state }) => {
if (this.props.displayMode !== 'full') return
const popperOffsets = state.modifiersData.popperOffsets
if (popperOffsets === undefined) return
const offsets = { ...popperOffsets }
const siblings = this.props.siblings.filter((s) => s.props.displayMode === 'full')
let yOffset = 0
for (const sibling of siblings) {
yOffset = Math.max(yOffset, sibling.rect.y)
yOffset += sibling.rect.height + commentGap
}
offsets.y = Math.max(offsets.y, yOffset)
offsets.x += commentGap
state.modifiersData.popperOffsets = offsets
}
}
]
}
})
this.destructors.push(() => this.tippynode?.destroy())
this.renderer = new SvelteRenderer(this.props.component, {
element: this.container,
props: this.buildComponentProps(view)
})
}
if (this.props.displayMode !== prevprops.displayMode) {
this.tippynode?.setProps({ placement, getReferenceClientRect, duration: 0 })
} else {
this.tippynode?.setProps({ duration: 100 })
}
if (this.props.showPopup) {
this.tippynode?.show()
} else {
this.tippynode?.hide()
}
if (props !== undefined && this.props.showPopup) {
this.renderer?.updateProps(this.buildComponentProps(view))
}
this.tippynode?.setProps({})
return document.createElement('span')
}
destroy (): void {
for (const fn of this.destructors) fn()
}
}
function eqSets<T> (xs: Set<T>, ys: Set<T>): boolean {
return xs.size === ys.size && [...xs].every((x) => ys.has(x))
}
function forceUpdateDecorations (view: EditorView): void {
view.dispatch(setMeta(view.state.tr, {}))
}
function fetchCommentThreads (options: InlineCommentExtensionOptions): Map<string, Thread> {
const commentMap = getYDocCommentMap(options)
const threads = new Map<string, Thread>()
for (const comment of commentMap.values()) {
const thread = threads.get(comment.thread) ?? {
_id: comment.thread,
_class: core.class.Obj,
messages: []
}
thread.messages.push(comment)
threads.set(thread._id, thread)
}
return threads
}
function resolveThread (options: InlineCommentExtensionOptions, editorView: EditorView, thread: string): void {
const view = getCommentDecoratorState(editorView.state)?.commentViews.get(thread)
if (view === undefined) return
const commentMap = getYDocCommentMap(options)
const keys = Array.from(commentMap.keys())
for (const key of keys) {
const comment = commentMap.get(key)
if (comment?.thread === thread) commentMap.delete(key)
}
const { from, to } = view.props.range
const mark = view.props.mark ?? editorView.state.schema.marks['inline-comment']
editorView.dispatch(setMeta(editorView.state.tr.removeMark(from, to, mark), {}))
}
function updateThreadComment (
options: InlineCommentExtensionOptions,
editorView: EditorView,
patch: { _id?: string, thread: string, message: string }
): void {
const view = getCommentDecoratorState(editorView.state)?.commentViews.get(patch.thread)
const commentMap = getYDocCommentMap(options)
const existingComment: InlineComment | undefined = patch._id !== undefined ? commentMap.get(patch._id) : undefined
const account = getCurrentAccount()
const comment: InlineComment =
existingComment !== undefined
? {
...existingComment,
message: patch.message,
editedOn: Date.now()
}
: {
_id: generateId(),
_class: core.class.Obj,
thread: patch.thread,
createdBy: account._id,
createdOn: Date.now(),
message: patch.message
}
if (comment.message !== '') {
commentMap.set(comment._id, comment)
} else {
commentMap.delete(comment._id)
}
const thread = fetchCommentThreads(options).get(comment.thread)
if (thread === undefined) return
if (view !== undefined) {
const { from, to } = view.props.range
let tr = setMeta(editorView.state.tr, {})
if (thread.messages.length === 0) {
tr = tr.setSelection(TextSelection.create(editorView.state.doc, 0))
}
if (thread.messages.length === 1 && existingComment === undefined) {
const mark = editorView.state.schema.mark('inline-comment', { thread: thread._id })
tr = tr.addMark(from, to, mark)
}
editorView.dispatch(tr)
}
}
function updatePointerState (view: EditorView, patch: Partial<PointerState>): void {
const state = getCommentDecoratorState(view.state)
if (state === undefined) return
const pointer = { ...state.pointer }
let isUpdated = false
for (const property in patch) {
const key = property as keyof PointerState
const newState = patch[key]
if (newState === undefined || eqSets(pointer[key], newState)) {
continue
}
isUpdated = true
pointer[key] = newState
}
if (isUpdated) {
view.dispatch(setMeta(view.state.tr, { pointer }))
}
}
type NodeMark = Node['marks'][0]
function getThreadsFromNode (threads: Map<string, Thread>, node: Node): Array<{ thread: Thread, mark: NodeMark }> {
const out: ReturnType<typeof getThreadsFromNode> = []
if (node.type.name !== 'text') return out
const marks = node?.marks?.filter((m) => m.type.name === 'inline-comment')
for (const mark of marks) {
const thread = threads.get(mark.attrs.thread)
if (thread === undefined) continue
out.push({ mark, thread })
}
return out
}
function observeSizeChanges (element: HTMLElement, callback: (width: number, height: number) => void): () => void {
let width = element.offsetWidth
let height = element.offsetHeight
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target !== element) continue
const w = entry.contentRect.width
const h = entry.contentRect.height
if (h !== height || w !== width) {
width = w
height = h
// This call should not be postponed via requestAnimationFrame,
// otherwise there will be noticeable glitches in rendering.
// However, there are some cases when rendering converges in several steps,
// which causes browser warning "ResizeObserver loop completed with undelivered notifications".
// (Which is generally fine according to the documentation of ResizeObserver itself).
// For this purpose it is appropriate to selectively disable this error in the webpack
// overlay for local development.
callback(width, height)
}
}
})
resizeObserver.observe(element)
return () => {
resizeObserver.disconnect()
}
}
function mapDecorationSet (set: DecorationSet, tr?: Transaction): DecorationSet {
return tr !== undefined ? set.map(tr.mapping, tr.doc) : set
}
function mapSelection (sel: Selection, tr?: Transaction): Selection {
return tr !== undefined ? sel.map(tr.doc, tr.mapping) : sel
}
function minmax (value = 0, min = 0, max = 0): number {
return Math.min(Math.max(value, min), max)
}
function getReferenceAnchor (view: EditorView, from: number, to: number): DOMRect {
const minPos = 0
const maxPos = view.state.doc.content.size
const resolvedFrom = minmax(from, minPos, maxPos)
const resolvedEnd = minmax(to, minPos, maxPos)
const start = view.coordsAtPos(resolvedFrom)
const end = view.coordsAtPos(resolvedEnd, -1)
const top = Math.min(start.top, end.top)
const editorRect = view.dom.getBoundingClientRect()
const data = {
top,
bottom: top,
left: editorRect.left,
right: editorRect.right,
width: editorRect.width,
height: 0,
x: editorRect.x,
y: top
}
return {
...data,
toJSON: () => data
}
}
function getReferenceRect (view: EditorView, from: number, to: number): DOMRect {
const minPos = 0
const maxPos = view.state.doc.content.size
const resolvedFrom = minmax(from, minPos, maxPos)
const resolvedEnd = minmax(to, minPos, maxPos)
const start = view.coordsAtPos(resolvedFrom)
const end = view.coordsAtPos(resolvedEnd, -1)
const top = Math.min(start.top, end.top)
const bottom = Math.max(start.bottom, end.bottom)
const left = Math.min(start.left, end.left)
const right = Math.max(start.right, end.right)
const width = right - left
const height = bottom - top
const x = left
const y = top
const data = {
top,
bottom,
left,
right,
width,
height,
x,
y
}
return {
...data,
toJSON: () => data
}
}
export async function createInlineComment (editor: Editor, event: MouseEvent): Promise<void> {
editor.view.dispatch(setMeta(editor.state.tr, { newCommentRequested: true }))
}
function isSelectionContainedByRange (selection: Selection, from: number, to: number): boolean {
return selection.from >= from && selection.to <= to
}
export async function shouldShowCreateInlineCommentAction (editor: Editor, ctx: ActionContext): Promise<boolean> {
if (!editor.isEditable) {
return false
}
const extension = editor.extensionManager.extensions.find((e) => e.name === extensionName)
if (extension === undefined) return false
return true
}

View File

@ -20,6 +20,7 @@ import { isEditable, isHeadingVisible } from './kits/editor-kit'
import { openTableOptions, isEditableTableActive } from './components/extension/table/table'
import { openImage, downloadImage, expandImage, moreImageActions } from './components/extension/imageExt'
import { configureNote, isEditableNote } from './components/extension/note'
import { createInlineComment, shouldShowCreateInlineCommentAction } from './components/extension/inlineComment'
export * from '@hcengineering/presentation/src/types'
export type { EditorKitOptions } from './kits/editor-kit'
@ -88,6 +89,9 @@ export default async (): Promise<Resources> => ({
IsEditableTableActive: isEditableTableActive,
IsEditableNote: isEditableNote,
IsEditable: isEditable,
IsHeadingVisible: isHeadingVisible
IsHeadingVisible: isHeadingVisible,
CreateInlineComment: createInlineComment,
ShouldShowCreateInlineCommentAction: shouldShowCreateInlineCommentAction
}
})

View File

@ -57,6 +57,9 @@ export default plugin(textEditorId, {
CodeBlock: '' as IntlString,
Note: '' as IntlString,
ConfigureNote: '' as IntlString,
Comment: '' as IntlString,
AddComment: '' as IntlString,
AddCommentPlaceholder: '' as IntlString,
Set: '' as IntlString,
Update: '' as IntlString,
Remove: '' as IntlString,
@ -119,6 +122,7 @@ export default plugin(textEditorId, {
Expand: '' as Asset,
ScaleOut: '' as Asset,
Download: '' as Asset,
Note: '' as Asset
Note: '' as Asset,
Comment: '' as Asset
}
})