mirror of
https://github.com/hcengineering/platform.git
synced 2024-12-18 16:31:57 +03:00
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
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:
parent
c496829576
commit
2158661df5
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
})
|
||||
|
@ -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)
|
||||
}}
|
||||
|
@ -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%;
|
||||
}
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -130,6 +130,7 @@
|
||||
"StartConversation": "Начать диалог",
|
||||
"ViewingThreadFromArchivedChannel": "Вы просматриваете обсуждение из архивированного канала",
|
||||
"ViewingArchivedChannel": "Вы просматриваете архивированный канал",
|
||||
"OpenChatInSidebar": "Открыть чат в боковой панели"
|
||||
"OpenChatInSidebar": "Открыть чат в боковой панели",
|
||||
"ResolveThread": "Пометить завершенным"
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
@ -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>
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -271,6 +271,7 @@
|
||||
editorAttributes={{ style: 'padding: 0 2em; margin: 0 -2em;' }}
|
||||
overflow="none"
|
||||
canShowPopups={!$areDocumentCommentPopupsOpened}
|
||||
enableInlineComments={false}
|
||||
onExtensions={handleExtensions}
|
||||
kitOptions={{
|
||||
note: {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}}
|
||||
|
@ -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 |
@ -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..."
|
||||
}
|
||||
}
|
@ -59,6 +59,9 @@
|
||||
"SeparatorLine": "Разделительная линия",
|
||||
"TodoList": "Действие",
|
||||
"DrawingBoard": "Доска",
|
||||
"MermaidDiargram": "Диаграмма"
|
||||
"MermaidDiargram": "Диаграмма",
|
||||
"Comment": "Комментарий",
|
||||
"AddComment": "Добавить комментарий",
|
||||
"AddCommentPlaceholder": "Добавьте комментарий..."
|
||||
}
|
||||
}
|
@ -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`
|
||||
})
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user