UBERF-4569 Collaborative editors for Markup fields (#4247)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2023-12-25 16:49:08 +07:00 committed by GitHub
parent e65783bfd2
commit 2b4b97732e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 2227 additions and 936 deletions

4
.vscode/launch.json vendored
View File

@ -87,8 +87,10 @@
"request": "launch",
"args": ["src/__start.ts"],
"env": {
"SERVER_SECRET": "secret",
"SECRET": "secret",
"METRICS_CONSOLE": "true",
"TRANSACTOR_URL": "ws://localhost:3333",
"MONGO_URL": "mongodb://localhost:27017",
"MINIO_ACCESS_KEY": "minioadmin",
"MINIO_SECRET_KEY": "minioadmin",
"MINIO_ENDPOINT": "localhost"

View File

@ -17,6 +17,9 @@ dependencies:
'@hocuspocus/server':
specifier: ^2.5.0
version: 2.8.1(bufferutil@4.0.7)(yjs@13.6.8)
'@hocuspocus/transformer':
specifier: ^2.5.0
version: 2.8.1(@tiptap/pm@2.1.12)(y-prosemirror@1.2.1)(yjs@13.6.8)
'@koa/cors':
specifier: ^3.1.0
version: 3.4.3
@ -91,7 +94,7 @@ dependencies:
version: file:projects/client-resources.tgz(@types/node@16.11.68)(esbuild@0.16.17)(svelte@4.2.5)(ts-node@10.9.1)
'@rush-temp/collaborator':
specifier: file:./projects/collaborator.tgz
version: file:projects/collaborator.tgz(bufferutil@4.0.7)(svelte@4.2.5)
version: file:projects/collaborator.tgz(@tiptap/pm@2.1.12)(bufferutil@4.0.7)(prosemirror-model@1.19.3)(svelte@4.2.5)
'@rush-temp/contact':
specifier: file:./projects/contact.tgz
version: file:projects/contact.tgz(@types/node@16.11.68)(esbuild@0.16.17)(svelte@4.2.5)(ts-node@10.9.1)
@ -3686,6 +3689,20 @@ packages:
- utf-8-validate
dev: false
/@hocuspocus/transformer@2.8.1(@tiptap/pm@2.1.12)(y-prosemirror@1.2.1)(yjs@13.6.8):
resolution: {integrity: sha512-0WtE/fxGwD/BEdl22udznD+skggvF6D0kEpw7jTEUdfvqwSJEyUxq3tm1S1hVmZyQI21qDw6yTTgP7Ujf1WbSg==}
peerDependencies:
'@tiptap/pm': ^2.1.12
y-prosemirror: ^1.2.1
yjs: ^13.6.8
dependencies:
'@tiptap/core': 2.1.12(@tiptap/pm@2.1.12)
'@tiptap/pm': 2.1.12
'@tiptap/starter-kit': 2.1.12(@tiptap/pm@2.1.12)
y-prosemirror: 1.2.1(prosemirror-model@1.19.3)(yjs@13.6.8)
yjs: 13.6.8
dev: false
/@humanwhocodes/config-array@0.11.11:
resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==}
engines: {node: '>=10.10.0'}
@ -17202,7 +17219,7 @@ packages:
yjs: ^13.0.0
dependencies:
level: 6.0.1
lib0: 0.2.86
lib0: 0.2.88
yjs: 13.6.8
dev: false
optional: true
@ -17227,7 +17244,7 @@ packages:
peerDependencies:
yjs: ^13.0.0
dependencies:
lib0: 0.2.86
lib0: 0.2.88
yjs: 13.6.8
dev: false
@ -17406,7 +17423,7 @@ packages:
dev: false
file:projects/activity-resources.tgz(@types/node@16.11.68)(esbuild@0.16.17)(postcss-load-config@4.0.1)(postcss@8.4.31)(ts-node@10.9.1):
resolution: {integrity: sha512-F4DXewrthjnPi36RuMEgym+/VACMDSyCmP8QvXHUPvwu7x113/rtbhzOhSPSaj+jwZCzMiKTKXOU68B8Q1j+fw==, tarball: file:projects/activity-resources.tgz}
resolution: {integrity: sha512-S3azsw6dQwDQHF9dH26Ffp/Yje78ZWyL2Gv1xw9FRphd7bwsG8cz3/KgKHVjJ52Bg8lsuSAQZsUbUJqmn+U+QQ==, tarball: file:projects/activity-resources.tgz}
id: file:projects/activity-resources.tgz
name: '@rush-temp/activity-resources'
version: 0.0.0
@ -18143,13 +18160,16 @@ packages:
- ts-node
dev: false
file:projects/collaborator.tgz(bufferutil@4.0.7)(svelte@4.2.5):
resolution: {integrity: sha512-Aqxt81MeApDyPNFAK2c+YaKTe5kbBZdHrL/7UgrXClC3hqN7tVepxeWK77TaSz/Sk9/1+dOYphDBMBYK5jOxGQ==, tarball: file:projects/collaborator.tgz}
file:projects/collaborator.tgz(@tiptap/pm@2.1.12)(bufferutil@4.0.7)(prosemirror-model@1.19.3)(svelte@4.2.5):
resolution: {integrity: sha512-37tcslNB1OuZncrABis1O54JHogvoU8oT3FYo91epmBvzoriNrS6wYuV2Odc+UI4frX6bZGQJEIYxmNYLNMajQ==, tarball: file:projects/collaborator.tgz}
id: file:projects/collaborator.tgz
name: '@rush-temp/collaborator'
version: 0.0.0
dependencies:
'@hocuspocus/server': 2.8.1(bufferutil@4.0.7)(yjs@13.6.8)
'@hocuspocus/transformer': 2.8.1(@tiptap/pm@2.1.12)(y-prosemirror@1.2.1)(yjs@13.6.8)
'@tiptap/core': 2.1.12(@tiptap/pm@2.1.12)
'@tiptap/html': 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@2.1.12)
'@types/body-parser': 1.19.3
'@types/compression': 1.7.3
'@types/cors': 2.8.14
@ -18171,22 +18191,29 @@ packages:
eslint-plugin-promise: 6.1.1(eslint@8.54.0)
express: 4.18.2
jest: 29.7.0(@types/node@16.11.68)(ts-node@10.9.1)
mongodb: 4.17.1
prettier: 3.1.0
prettier-plugin-svelte: 3.1.0(prettier@3.1.0)(svelte@4.2.5)
ts-jest: 29.1.1(esbuild@0.16.17)(jest@29.7.0)(typescript@5.2.2)
ts-node: 10.9.1(@types/node@16.11.68)(typescript@5.2.2)
typescript: 5.2.2
ws: 8.14.2(bufferutil@4.0.7)
y-prosemirror: 1.2.1(prosemirror-model@1.19.3)(yjs@13.6.8)
yjs: 13.6.8
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- '@swc/core'
- '@swc/wasm'
- '@tiptap/pm'
- aws-crt
- babel-jest
- babel-plugin-macros
- bufferutil
- node-notifier
- prosemirror-model
- prosemirror-state
- prosemirror-view
- supports-color
- svelte
- utf-8-validate
@ -19447,7 +19474,7 @@ packages:
dev: false
file:projects/model-all.tgz(svelte@4.2.5)(typescript@5.2.2):
resolution: {integrity: sha512-PEl4zAGjvSsQ5stSRUuj2PYcZ0BdWTkxyaJNP63dbX4U6QA5sf7BuXamJRgETTD74jA40pGRXUAMnprS6t9z4w==, tarball: file:projects/model-all.tgz}
resolution: {integrity: sha512-bqRngFhfXoPedb0IMaXya9LnZ5agvGBsd49EUmpJuM/BWIDMW8Oj4iU2fnkTVYQEiBsprhXX1POk4chGR6dHvA==, tarball: file:projects/model-all.tgz}
id: file:projects/model-all.tgz
name: '@rush-temp/model-all'
version: 0.0.0
@ -19811,7 +19838,7 @@ packages:
dev: false
file:projects/model-server-activity.tgz(svelte@4.2.5)(typescript@5.2.2):
resolution: {integrity: sha512-ddPj4Y8/1G2ItDMmgLbaasdkjTTY66I3XKmyV3KfZNjeRq7DJZquF5VG1GM09TpfAkQkLJBJcoHoqtqQ9OgluA==, tarball: file:projects/model-server-activity.tgz}
resolution: {integrity: sha512-n6L6M7urgihLXLCFZWoh8LlVt+8dQHiGaehmAG4KJ1X3pz8xmSnZAjUAeLdXZvuRHFcfTPyHv0YzDBJ+npXxHg==, tarball: file:projects/model-server-activity.tgz}
id: file:projects/model-server-activity.tgz
name: '@rush-temp/model-server-activity'
version: 0.0.0
@ -20021,7 +20048,7 @@ packages:
dev: false
file:projects/model-server-notification.tgz(svelte@4.2.5)(typescript@5.2.2):
resolution: {integrity: sha512-BmWHvQtuRizjJE87k3thPWrhHJBfOEx1tg62HmPHUMsWxoS6nBKVzTa4zzwFqHPb4tlzBS7S8ux1n5tTwtJiMQ==, tarball: file:projects/model-server-notification.tgz}
resolution: {integrity: sha512-csNOi/1jDDopp9M7/9nlneRsjWLZ8vMj16CsAYirsR7PtPRYXoC0jLwzIUCnscdJry8KH7l9oZm8DbszdunklA==, tarball: file:projects/model-server-notification.tgz}
id: file:projects/model-server-notification.tgz
name: '@rush-temp/model-server-notification'
version: 0.0.0
@ -20294,7 +20321,7 @@ packages:
dev: false
file:projects/model-survey.tgz(svelte@4.2.5)(typescript@5.2.2):
resolution: {integrity: sha512-Q15RJl+fvfRN7hsIQUZ4UzcqF524kBjhNiaFimmUJCkTlOjhUhhn04pjcSFsh7g+7KbVfZW3CyD7L9ZQVqHp4w==, tarball: file:projects/model-survey.tgz}
resolution: {integrity: sha512-HqvLuhQJO8PpOeuDEPr7dcrxA2xZKXp7FLIwdckKONYx506mZVzErCiU3eXy3M4YyLXIQCTy272vnxJZnemJTw==, tarball: file:projects/model-survey.tgz}
id: file:projects/model-survey.tgz
name: '@rush-temp/model-survey'
version: 0.0.0
@ -20336,7 +20363,7 @@ packages:
dev: false
file:projects/model-task.tgz(svelte@4.2.5)(typescript@5.2.2):
resolution: {integrity: sha512-56qSfouGLl0idf3X7qFQVjeAmCT0eg2iDPoErUQzeQpaYgJ2sbI1wp0Czj3hDm83NlA0eiIYkOIgqtHuSULM7w==, tarball: file:projects/model-task.tgz}
resolution: {integrity: sha512-P62KW6rPhZBuQdKMpqqnyMoKzw3Zd2057wyJxWQ0jOB9q9dd0XgFtsmMWAX/18O83aFY92sbqoPg/EPGTLV6zA==, tarball: file:projects/model-task.tgz}
id: file:projects/model-task.tgz
name: '@rush-temp/model-task'
version: 0.0.0
@ -20977,7 +21004,7 @@ packages:
dev: false
file:projects/pod-server.tgz(svelte@4.2.5):
resolution: {integrity: sha512-7Griqc83Xh/jubUWlO8qA+Cx1+2pjzZgouGteeQZXfCokol6X813h3O5vkKO3fenjg6doFII5zI+DJx1R4jBnQ==, tarball: file:projects/pod-server.tgz}
resolution: {integrity: sha512-P3qTbPOzsBBFCp/JsSNERRx6ITah+3bfpBDqPgP4dzydWPEcbW9AALPYjrYviv/GRZuFzgcBu32ZxrUMCZ6DwA==, tarball: file:projects/pod-server.tgz}
id: file:projects/pod-server.tgz
name: '@rush-temp/pod-server'
version: 0.0.0
@ -21125,7 +21152,7 @@ packages:
dev: false
file:projects/prod.tgz(bufferutil@4.0.7)(esbuild@0.16.17)(sass@1.69.0)(ts-node@10.9.1)(typescript@5.2.2):
resolution: {integrity: sha512-AMQUJ8O71HSvI9iGDSj/32iyRs75Qepku6ir0Xq6n2/IwVW4BE3n0Fzhh5tfFTsbPtjL2p4qcA0mmJ0zg28D+g==, tarball: file:projects/prod.tgz}
resolution: {integrity: sha512-RpgrKzlPUDZ5L+pT2NPQmXxUomiw84N6898NdEYNbbXERliDL6II+7Qw9LwoEBHtVDChgnU8ez+5WcuknHrjlw==, tarball: file:projects/prod.tgz}
id: file:projects/prod.tgz
name: '@rush-temp/prod'
version: 0.0.0
@ -21526,7 +21553,7 @@ packages:
dev: false
file:projects/server-activity.tgz(esbuild@0.16.17)(svelte@4.2.5)(ts-node@10.9.1):
resolution: {integrity: sha512-DneWyxHhW1vXrtKGwcLBuE7tkcbRkQVg4L/X4YamviGOuSWNSwUMb4KeJk7tqRhNwt0ldg3GcXvP7p01oPyVnA==, tarball: file:projects/server-activity.tgz}
resolution: {integrity: sha512-VDiyr4T43YaFFBX7WNa9eesnQK7F2dij3fbm62nMM+RV4iQBzOMJwBWAZAGEnHAkPQNMKSwnzqUf2N0barvTjg==, tarball: file:projects/server-activity.tgz}
id: file:projects/server-activity.tgz
name: '@rush-temp/server-activity'
version: 0.0.0
@ -23169,7 +23196,7 @@ packages:
dev: false
file:projects/survey-assets.tgz(esbuild@0.16.17)(svelte@4.2.5)(ts-node@10.9.1)(typescript@5.2.2):
resolution: {integrity: sha512-ERl1tCl6UFla9peQ8yjEtMIgDr6AhFGxfp5qEH7yi7H0CFrBYddSj5euGsvHwYnowtshRsAI5YKw4sUhEeTVxA==, tarball: file:projects/survey-assets.tgz}
resolution: {integrity: sha512-KqJbIkEBJ7f4dqGEd1ztPbMCP6l6l+wdYOTr83WN37YOUj8ucMuTcDWcZv1aCMZl/T/C6EUIDf5TgYvl1CHjcw==, tarball: file:projects/survey-assets.tgz}
id: file:projects/survey-assets.tgz
name: '@rush-temp/survey-assets'
version: 0.0.0
@ -23201,7 +23228,7 @@ packages:
dev: false
file:projects/survey-resources.tgz(@types/node@16.11.68)(esbuild@0.16.17)(postcss-load-config@4.0.1)(postcss@8.4.31)(svelte@4.2.5)(ts-node@10.9.1)(typescript@5.2.2):
resolution: {integrity: sha512-7TYqfVbpbPrAl/+fBGHT78DkkQo5+ZdIO7sIf+Z7UVEoNYzmnZnM0VCmnzdpAeOjZ64Z+kLG3onaLYbFf6awDQ==, tarball: file:projects/survey-resources.tgz}
resolution: {integrity: sha512-S4t+6+J3q1YibQwIzBOYpeWlSPTiFyvVhnhw07Eqnxy1z3ADHLlHpsxCXUI+zQQQzzo9zvGLRoK1hSYmtcPGfA==, tarball: file:projects/survey-resources.tgz}
id: file:projects/survey-resources.tgz
name: '@rush-temp/survey-resources'
version: 0.0.0
@ -23246,7 +23273,7 @@ packages:
dev: false
file:projects/survey.tgz(@types/node@16.11.68)(esbuild@0.16.17)(svelte@4.2.5)(ts-node@10.9.1)(typescript@5.2.2):
resolution: {integrity: sha512-meZo7FpLblppewqgFsGx3Mh6AS7deFcxC8D9vnRRWliUuGpYfq+7K3/T++j38D2RHZVGYnJTjHJnCMXTnTSPfA==, tarball: file:projects/survey.tgz}
resolution: {integrity: sha512-VQ5Ne0MuO8zCvfvMqROddHTfinHEmiybMnz3XM/YfxOTxU1RRwZDGSoaixBU/tK98BURHp+NDnCiD5X3Q48DtQ==, tarball: file:projects/survey.tgz}
id: file:projects/survey.tgz
name: '@rush-temp/survey'
version: 0.0.0
@ -23420,7 +23447,7 @@ packages:
dev: false
file:projects/task-resources.tgz(@types/node@16.11.68)(esbuild@0.16.17)(postcss-load-config@4.0.1)(postcss@8.4.31)(ts-node@10.9.1)(typescript@5.2.2):
resolution: {integrity: sha512-rmEmBZU8K4rPiePhlw99tsYnCiYoXlymTECYCm29m3zOEcKsLptEL5EPcI1YgJd6+83LOfAEHxYFLXcFgXWe+A==, tarball: file:projects/task-resources.tgz}
resolution: {integrity: sha512-sXLi/UksIrEaJL0K+aSQcqUSm8jJv9qeyEEJMiENDxjwvCDzONTz8HPwFjRVUdPSH6Mnz9YpNRwQyIDOpni0qg==, tarball: file:projects/task-resources.tgz}
id: file:projects/task-resources.tgz
name: '@rush-temp/task-resources'
version: 0.0.0
@ -23466,7 +23493,7 @@ packages:
dev: false
file:projects/task.tgz(@types/node@16.11.68)(esbuild@0.16.17)(svelte@4.2.5)(ts-node@10.9.1):
resolution: {integrity: sha512-IH/xqCjSTuVcbWA+pbE/dcZeChW7N4+jJiVCbwGDB605yipH/IDYSz1fsf+evXbkBm8lSHBnrzQf2ovVWk+Iig==, tarball: file:projects/task.tgz}
resolution: {integrity: sha512-qtjMbTtDdi6Zz2GIi6HXzFOVPYjrC35i3m/3E6cJcbFG7LzRv/QU9guhZl5M3zuSmymP0m6wxtJ3C4CiboCMJQ==, tarball: file:projects/task.tgz}
id: file:projects/task.tgz
name: '@rush-temp/task'
version: 0.0.0
@ -23746,7 +23773,7 @@ packages:
dev: false
file:projects/text-editor.tgz(@types/node@16.11.68)(bufferutil@4.0.7)(esbuild@0.16.17)(postcss-load-config@4.0.1)(postcss@8.4.31)(prosemirror-model@1.19.3)(ts-node@10.9.1):
resolution: {integrity: sha512-s3a0AnlJS8WBnLjA5ggHaLcbSh9tT9DA6Szy9wYWGJzt9R82/nfhh4qMOLWbvHbTgOu/ieWGrhG2FpWAzHSwzw==, tarball: file:projects/text-editor.tgz}
resolution: {integrity: sha512-42rs06vaqb/uM49NzAf4ggZxatYbBqfT2Qy0rZLsK72tSUAxZC86E97pxUjYaLKguUoAGkou/bQf2iOFySZQwA==, tarball: file:projects/text-editor.tgz}
id: file:projects/text-editor.tgz
name: '@rush-temp/text-editor'
version: 0.0.0

View File

@ -94,6 +94,7 @@ services:
collaborator:
image: hardcoreeng/collaborator
links:
- mongodb
- minio
- transactor
ports:
@ -102,6 +103,7 @@ services:
- COLLABORATOR_PORT=3078
- SECRET=secret
- TRANSACTOR_URL=ws://localhost:3333
- MONGO_URL=mongodb://mongodb:27017
- MINIO_ENDPOINT=minio
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin

View File

@ -199,6 +199,10 @@ export class TTypeNumber extends TType {}
@Model(core.class.TypeMarkup, core.class.Type)
export class TTypeMarkup extends TType {}
@UX(core.string.Collaborative)
@Model(core.class.TypeCollaborativeMarkup, core.class.Type)
export class TTypeCollaborativeMarkup extends TType {}
@UX(core.string.Ref)
@Model(core.class.RefTo, core.class.Type)
export class TRefTo extends TType implements RefTo<Doc> {

View File

@ -51,6 +51,7 @@ import {
TType,
TTypeAttachment,
TTypeBoolean,
TTypeCollaborativeMarkup,
TTypeDate,
TTypeHyperlink,
TTypeIntlString,
@ -108,6 +109,7 @@ export function createModel (builder: Builder): void {
TType,
TEnumOf,
TTypeMarkup,
TTypeCollaborativeMarkup,
TArrOf,
TRefTo,
TTypeDate,

View File

@ -25,6 +25,7 @@ import {
Model,
Prop,
ReadOnly,
TypeCollaborativeMarkup,
TypeDate,
TypeMarkup,
TypeRef,
@ -94,7 +95,7 @@ export class TCustomer extends TContact implements Customer {
@Prop(Collection(lead.class.Lead), lead.string.Leads)
leads?: number
@Prop(TypeMarkup(), core.string.Description)
@Prop(TypeCollaborativeMarkup(), core.string.Description)
@Index(IndexKind.FullText)
description!: string
}

View File

@ -25,6 +25,7 @@ import {
Prop,
ReadOnly,
TypeBoolean,
TypeCollaborativeMarkup,
TypeDate,
TypeMarkup,
TypeRef,
@ -69,7 +70,7 @@ export { default } from './plugin'
@Model(recruit.class.Vacancy, task.class.Project)
@UX(recruit.string.Vacancy, recruit.icon.Vacancy, 'VCN', 'name')
export class TVacancy extends TProject implements Vacancy {
@Prop(TypeMarkup(), recruit.string.FullDescription)
@Prop(TypeCollaborativeMarkup(), recruit.string.FullDescription)
@Index(IndexKind.FullText)
fullDescription?: string

View File

@ -13,6 +13,7 @@
// limitations under the License.
//
import chunter from '@hcengineering/chunter'
import contact, { type Employee, type Person } from '@hcengineering/contact'
import {
DOMAIN_MODEL,
@ -33,6 +34,7 @@ import {
Model,
Prop,
ReadOnly,
TypeCollaborativeMarkup,
TypeDate,
TypeMarkup,
TypeNumber,
@ -64,7 +66,6 @@ import {
type TimeSpendReport
} from '@hcengineering/tracker'
import tracker from './plugin'
import chunter from '@hcengineering/chunter'
export const DOMAIN_TRACKER = 'tracker' as Domain
@ -170,7 +171,7 @@ export class TIssue extends TTask implements Issue {
@Index(IndexKind.FullText)
title!: string
@Prop(TypeMarkup(), tracker.string.Description)
@Prop(TypeCollaborativeMarkup(), tracker.string.Description)
@Index(IndexKind.FullText)
description!: Markup
@ -258,7 +259,7 @@ export class TIssueTemplate extends TDoc implements IssueTemplate {
@Index(IndexKind.FullText)
title!: string
@Prop(TypeMarkup(), tracker.string.Description)
@Prop(TypeCollaborativeMarkup(), tracker.string.Description)
@Index(IndexKind.FullText)
description!: Markup

View File

@ -477,6 +477,11 @@ export function createModel (builder: Builder): void {
editor: view.component.HTMLEditor
})
classPresenter(builder, core.class.TypeCollaborativeMarkup, view.component.MarkupPresenter)
builder.mixin(core.class.TypeCollaborativeMarkup, core.class.Class, view.mixin.InlineAttributEditor, {
editor: view.component.CollaborativeHTMLEditor
})
classPresenter(builder, core.class.TypeBoolean, view.component.BooleanPresenter, view.component.BooleanEditor)
classPresenter(
builder,

View File

@ -68,6 +68,7 @@ export default mergeIds(viewId, view, {
EnumEditor: '' as AnyComponent,
EnumArrayEditor: '' as AnyComponent,
HTMLEditor: '' as AnyComponent,
CollaborativeHTMLEditor: '' as AnyComponent,
MarkupEditor: '' as AnyComponent,
MarkupEditorPopup: '' as AnyComponent,
ListView: '' as AnyComponent,

View File

@ -27,6 +27,7 @@
"Enum": "Enum",
"Members": "Members",
"Hyperlink": "URL",
"Collaborative": "Collaborative",
"Object": "Object",
"System": "System",
"CreatedBy": "Created by",

View File

@ -27,6 +27,7 @@
"Enum": "Справочник",
"Members": "Участники",
"Hyperlink": "URL",
"Collaborative": "Коллаборативный",
"Object": "Объект",
"System": "Система",
"CreatedBy": "Создан",

View File

@ -35,6 +35,7 @@ import type {
IndexStageState,
IndexingConfiguration,
Interface,
Markup,
MigrationState,
Obj,
PluginConfiguration,
@ -102,6 +103,7 @@ export default plugin(coreId, {
TypeBoolean: '' as Ref<Class<Type<boolean>>>,
TypeTimestamp: '' as Ref<Class<Type<Timestamp>>>,
TypeDate: '' as Ref<Class<Type<Timestamp | Date>>>,
TypeCollaborativeMarkup: '' as Ref<Class<Type<Markup>>>,
RefTo: '' as Ref<Class<RefTo<Doc>>>,
ArrOf: '' as Ref<Class<ArrOf<Doc>>>,
Enum: '' as Ref<Class<Enum>>,
@ -158,6 +160,7 @@ export default plugin(coreId, {
String: '' as IntlString,
Record: '' as IntlString,
Markup: '' as IntlString,
Collaborative: '' as IntlString,
Number: '' as IntlString,
Boolean: '' as IntlString,
Timestamp: '' as IntlString,

View File

@ -392,6 +392,13 @@ export function TypeMarkup (): Type<Markup> {
return { _class: core.class.TypeMarkup, label: core.string.Markup }
}
/**
* @public
*/
export function TypeCollaborativeMarkup (): Type<Markup> {
return { _class: core.class.TypeCollaborativeMarkup, label: core.string.Collaborative }
}
/**
* @public
*/

View File

@ -373,6 +373,9 @@ export function getAttributePresenterClass (
if (hierarchy.isDerived(attrClass, core.class.TypeMarkup)) {
category = 'inplace'
}
if (hierarchy.isDerived(attrClass, core.class.TypeCollaborativeMarkup)) {
category = 'inplace'
}
if (hierarchy.isDerived(attrClass, core.class.Collection)) {
attrClass = (attribute.type as Collection<AttachedDoc>).of
category = 'collection'

View File

@ -16,15 +16,19 @@
-->
<script lang="ts">
import { onDestroy, setContext } from 'svelte'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { TiptapCollabProvider, createTiptapCollaborationData } from '../provider'
import textEditorPlugin from '../plugin'
import { DocumentId, TiptapCollabProvider, createTiptapCollaborationData } from '../provider'
import { CollaborationIds } from '../types'
export let documentId: string
export let token: string
export let collaboratorURL: string
export let documentId: DocumentId
export let initialContentId: DocumentId | undefined = undefined
export let targetContentId: DocumentId | undefined = undefined
export let initialContentId: string | undefined = undefined
const token = getMetadata(presentation.metadata.Token) ?? ''
const collaboratorURL = getMetadata(textEditorPlugin.metadata.CollaboratorUrl) ?? ''
let _documentId = ''
@ -39,6 +43,7 @@
collaboratorURL,
documentId,
initialContentId,
targetContentId,
token
})
provider = data.provider

View File

@ -0,0 +1,99 @@
<!--
// 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 { Doc } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { KeyedAttribute } from '@hcengineering/presentation'
import { registerFocus } from '@hcengineering/ui'
import CollaborativeTextEditor from './CollaborativeTextEditor.svelte'
import { FocusExtension } from './extension/focus'
import { FileAttachFunction } from './extension/imageExt'
import textEditorPlugin from '../plugin'
import { minioDocumentId, mongodbDocumentId, platformDocumentId } from '../provider'
import { RefAction, TextNodeAction } from '../types'
export let object: Doc
export let key: KeyedAttribute
export let textNodeActions: TextNodeAction[] = []
export let refActions: RefAction[] = []
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let attachFile: FileAttachFunction | undefined = undefined
export let boundary: HTMLElement | undefined = undefined
export let focusIndex = -1
let editor: CollaborativeTextEditor
$: documentId = minioDocumentId(object._id, key)
$: initialContentId = mongodbDocumentId(object._id, key)
$: targetContentId = platformDocumentId(object._id, key)
// Focusable control with index
let canBlur = true
const { idx, focusManager } = registerFocus(focusIndex, {
focus: () => {
const editable: boolean = editor?.isEditable() ?? false
if (editable) {
editor?.focus()
}
return editable
},
isFocus: () => editor.isFocused(),
canBlur: () => {
const focused: boolean = editor?.isFocused() ?? false
if (focused) {
return canBlur
}
return true
}
})
const updateFocus = (): void => {
if (focusIndex !== -1) {
focusManager?.setFocus(idx)
}
}
const handleFocus = (focused: boolean): void => {
if (focused) {
updateFocus()
}
}
const extensions = [
FocusExtension.configure({
onCanBlur: (value: boolean) => (canBlur = value),
onFocus: handleFocus
})
]
</script>
<CollaborativeTextEditor
bind:this={editor}
{documentId}
{initialContentId}
{targetContentId}
{textNodeActions}
{refActions}
{extensions}
{attachFile}
{placeholder}
{boundary}
field={key.key}
on:focus
on:blur
on:update
/>

View File

@ -0,0 +1,44 @@
<!--
// 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 { Doc } from '@hcengineering/core'
import { IntlString, Asset } from '@hcengineering/platform'
import { KeyedAttribute } from '@hcengineering/presentation'
import { Label, Icon } from '@hcengineering/ui'
import type { AnySvelteComponent } from '@hcengineering/ui'
import textEditorPlugin from '../plugin'
import CollaborativeAttributeBox from './CollaborativeAttributeBox.svelte'
import IconDescription from './icons/Description.svelte'
export let object: Doc
export let key: KeyedAttribute
export let label: IntlString = textEditorPlugin.string.FullDescription
export let icon: Asset | AnySvelteComponent = IconDescription
let element: HTMLElement | undefined
</script>
<div class="antiSection" bind:this={element}>
<div class="antiSection-header mb-3">
<div class="antiSection-header__icon">
<Icon {icon} size={'small'} />
</div>
<span class="antiSection-header__title">
<Label {label} />
</span>
</div>
<CollaborativeAttributeBox {object} {key} boundary={element?.parentElement ?? undefined} on:focus on:blur on:update />
</div>

View File

@ -0,0 +1,393 @@
<!--
//
// Copyright © 2022, 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 { getCurrentAccount } from '@hcengineering/core'
import { IntlString, getMetadata, translate } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { Button, IconSize, Loading, getPlatformColorForText, themeStore } from '@hcengineering/ui'
import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
import Collaboration, { isChangeOrigin } from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import Placeholder from '@tiptap/extension-placeholder'
import { createEventDispatcher, getContext, onDestroy, onMount } from 'svelte'
import { Doc as YDoc } from 'yjs'
import { Completion } from '../Completion'
import { textEditorCommandHandler } from '../commands'
import textEditorPlugin from '../plugin'
import { DocumentId, TiptapCollabProvider } from '../provider'
import {
CollaborationIds,
RefAction,
TextEditorCommandHandler,
TextEditorHandler,
TextFormatCategory,
TextNodeAction
} from '../types'
import { copyDocumentContent, copyDocumentField } from '../utils'
import ImageStyleToolbar from './ImageStyleToolbar.svelte'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
import { noSelectionRender } from './editor/collaboration'
import { defaultEditorAttributes } from './editor/editorProps'
import { EmojiExtension } from './extension/emoji'
import { FileAttachFunction, ImageExtension } from './extension/imageExt'
import { InlinePopupExtension } from './extension/inlinePopup'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import { completionConfig, defaultExtensions } from './extensions'
export let documentId: DocumentId
export let field: string | undefined = undefined
export let initialContentId: DocumentId | undefined = undefined
export let targetContentId: DocumentId | undefined = undefined
export let readonly = false
export let buttonSize: IconSize = 'small'
export let actionsButtonSize: IconSize = 'medium'
export let full: boolean = false
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let extensions: AnyExtension[] = []
export let textFormatCategories: TextFormatCategory[] = [
TextFormatCategory.Heading,
TextFormatCategory.TextDecoration,
TextFormatCategory.Link,
TextFormatCategory.List,
TextFormatCategory.Quote,
TextFormatCategory.Code,
TextFormatCategory.Table
]
export let textNodeActions: TextNodeAction[] = []
export let refActions: RefAction[] = []
export let editorAttributes: Record<string, string> = {}
export let overflow: 'auto' | 'none' = 'none'
export let boundary: HTMLElement | undefined = undefined
export let attachFile: FileAttachFunction | undefined = undefined
export let canShowPopups = true
const dispatch = createEventDispatcher()
const token = getMetadata(presentation.metadata.Token) ?? ''
const collaboratorURL = getMetadata(textEditorPlugin.metadata.CollaboratorUrl) ?? ''
const ydoc = getContext<YDoc>(CollaborationIds.Doc) ?? new YDoc()
const contextProvider = getContext<TiptapCollabProvider>(CollaborationIds.Provider)
const provider: TiptapCollabProvider =
contextProvider ??
new TiptapCollabProvider({
url: collaboratorURL,
name: documentId,
document: ydoc,
token,
parameters: {
initialContentId,
targetContentId
}
})
let loading = true
void provider.loaded.then(() => (loading = false))
const currentUser = getCurrentAccount()
let editor: Editor
let element: HTMLElement
let textToolbarElement: HTMLElement
let imageToolbarElement: HTMLElement
let placeHolderStr: string = ''
$: ph = translate(placeholder, {}, $themeStore.language).then((r) => {
placeHolderStr = r
})
$: dispatch('editor', editor)
const editorHandler: TextEditorHandler = {
insertText: (text) => {
editor?.commands.insertContent(text)
},
insertTemplate: (name, text) => {
editor?.commands.insertContent(text)
},
focus: () => {
focus()
}
}
function handleAction (a: RefAction, evt?: Event): void {
a.action(evt?.target as HTMLElement, editorHandler)
}
$: commandHandler = textEditorCommandHandler(editor)
export function commands (): TextEditorCommandHandler | undefined {
return commandHandler
}
export function takeSnapshot (snapshotId: string): void {
copyDocumentContent(documentId, snapshotId, { provider }, initialContentId)
}
export function copyField (srcFieldId: string, dstFieldId: string): void {
copyDocumentField(documentId, srcFieldId, dstFieldId, { provider }, initialContentId)
}
export function isEditable (): boolean {
return editor?.isEditable ?? false
}
let needFocus = false
let focused = false
let posFocus: FocusPosition | undefined = undefined
export function focus (position?: FocusPosition): void {
posFocus = position
needFocus = true
}
export function isFocused (): boolean {
return focused
}
$: if (editor !== undefined && needFocus) {
if (!focused) {
editor.commands.focus(posFocus)
posFocus = undefined
}
needFocus = false
}
function handleFocus (): void {
needFocus = true
}
$: if (editor !== undefined) {
editor.setEditable(!readonly)
}
$: showTextStyleToolbar =
(!readonly || textFormatCategories.length > 0 || textNodeActions.length > 0) && canShowPopups
$: tippyOptions = {
zIndex: 100000,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary,
padding: 8,
altAxis: true,
tether: false
}
}
]
}
}
const optionalExtensions: AnyExtension[] = []
if (attachFile !== undefined) {
optionalExtensions.push(
ImageExtension.configure({
inline: true,
attachFile
})
)
}
onMount(() => {
void ph.then(() => {
editor = new Editor({
element,
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) },
extensions: [
...defaultExtensions,
...optionalExtensions,
Placeholder.configure({ placeholder: placeHolderStr }),
InlineStyleToolbarExtension.configure({
tippyOptions,
element: textToolbarElement,
isSupported: () => showTextStyleToolbar,
isSelectionOnly: () => false
}),
InlinePopupExtension.configure({
pluginKey: 'show-image-actions-popup',
element: imageToolbarElement,
tippyOptions: {
...tippyOptions,
appendTo: () => boundary ?? element
},
shouldShow: ({ editor }) => {
if (readonly || !canShowPopups) {
return false
}
return editor.isActive('image')
}
}),
Collaboration.configure({
document: ydoc,
field
}),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.email,
color: getPlatformColorForText(currentUser.email, $themeStore.dark)
},
selectionRender: noSelectionRender
}),
Completion.configure({
...completionConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}
}),
EmojiExtension.configure(),
...extensions
],
parseOptions: {
preserveWhitespace: 'full'
},
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
},
onBlur: ({ event }) => {
focused = false
dispatch('blur', event)
},
onFocus: () => {
focused = true
dispatch('focus')
},
onUpdate: ({ transaction }) => {
// ignore non-document changes
if (!transaction.docChanged) return
// ignore non-local changes
if (isChangeOrigin(transaction)) return
dispatch('update')
}
})
})
})
onDestroy(() => {
if (editor !== undefined) {
try {
editor.destroy()
} catch (err: any) {}
if (contextProvider === undefined) {
provider.destroy()
}
}
})
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
style:overflow
class="ref-container clear-mins"
class:h-full={full}
on:click|preventDefault|stopPropagation={() => (needFocus = true)}
>
{#if loading}
<div class="flex p-3">
<Loading />
</div>
{/if}
<div class="text-editor-toolbar buttons-group xsmall-gap mb-4" bind:this={textToolbarElement}>
{#if showTextStyleToolbar}
<TextEditorStyleToolbar
textEditor={editor}
formatButtonSize={buttonSize}
{textFormatCategories}
{textNodeActions}
on:focus={handleFocus}
/>
{/if}
</div>
<div class="text-editor-toolbar buttons-group xsmall-gap mb-4" bind:this={imageToolbarElement}>
<ImageStyleToolbar textEditor={editor} formatButtonSize={buttonSize} on:focus={handleFocus} />
</div>
<div class="textInput">
<div class="select-text" class:hidden={loading} style="width: 100%;" bind:this={element} />
</div>
{#if refActions.length > 0}
<div class="buttons-panel flex-between clear-mins">
<div class="buttons-group xsmall-gap mt-3">
{#each refActions as a}
<Button
disabled={a.disabled}
icon={a.icon}
iconProps={{ size: actionsButtonSize }}
kind="ghost"
showTooltip={{ label: a.label }}
size="medium"
on:click={(evt) => {
handleAction(a, evt)
}}
/>
{#if a.order % 10 === 1}
<div class="buttons-divider" />
{/if}
{/each}
</div>
</div>
{/if}
</div>
<style lang="scss">
.ref-container {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.text-editor-toolbar {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--button-shadow);
z-index: 1;
}
.textInput {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: flex-end;
min-height: 1.25rem;
background-color: transparent;
}
.hidden {
display: none;
}
</style>

View File

@ -15,50 +15,30 @@
//
-->
<script lang="ts">
import { getCurrentAccount } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { IconSize, Loading, getPlatformColorForText, registerFocus, themeStore } from '@hcengineering/ui'
import { AnyExtension, Editor, FocusPosition, getMarkRange, mergeAttributes } from '@tiptap/core'
import Collaboration, { isChangeOrigin } from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import Placeholder from '@tiptap/extension-placeholder'
import { IntlString } from '@hcengineering/platform'
import { IconSize, registerFocus } from '@hcengineering/ui'
import { AnyExtension, Editor, FocusPosition, getMarkRange } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import { createEventDispatcher, getContext, onDestroy, onMount } from 'svelte'
import * as Y from 'yjs'
import { Completion } from '../Completion'
import { textEditorCommandHandler } from '../commands'
import textEditorPlugin from '../plugin'
import { TiptapCollabProvider } from '../provider'
import { CollaborationIds, TextEditorCommandHandler, TextFormatCategory, TextNodeAction } from '../types'
import { copyDocumentContent, copyDocumentField } from '../utils'
import { DocumentId } from '../provider'
import { TextEditorCommandHandler, TextNodeAction } from '../types'
import ImageStyleToolbar from './ImageStyleToolbar.svelte'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
import { noSelectionRender } from './editor/collaboration'
import { defaultEditorAttributes } from './editor/editorProps'
import { FileAttachFunction, ImageExtension } from './extension/imageExt'
import { InlinePopupExtension } from './extension/inlinePopup'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import CollaborativeTextEditor from './CollaborativeTextEditor.svelte'
import { FileAttachFunction } from './extension/imageExt'
import { NodeUuidExtension, nodeElementQuerySelector } from './extension/nodeUuid'
import { completionConfig, defaultExtensions } from './extensions'
export let documentId: string
export let documentId: DocumentId
export let field: string | undefined = undefined
export let initialContentId: DocumentId | undefined = undefined
export let targetContentId: DocumentId | undefined = undefined
export let readonly = false
export let visible = true
export let token: string = ''
export let collaboratorURL: string = ''
export let buttonSize: IconSize = 'small'
export let focusable: boolean = false
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let initialContentId: string | undefined = undefined
export let field: string | undefined = undefined
export let overflow: 'auto' | 'none' = 'auto'
export let initialContent: string | undefined = undefined
export let textNodeActions: TextNodeAction[] = []
export let editorAttributes: Record<string, string> = {}
export let onExtensions: () => AnyExtension[] = () => []
@ -69,51 +49,22 @@
let element: HTMLElement
const ydoc: any = getContext(CollaborationIds.Doc) ?? new Y.Doc()
const contextProvider = getContext(CollaborationIds.Provider)
const provider: any =
contextProvider ??
new TiptapCollabProvider({
url: collaboratorURL,
name: documentId,
document: ydoc,
token,
parameters: {
initialContentId: initialContentId ?? ''
}
})
let loading = true
provider.loaded.then(() => (loading = false))
const currentUser = getCurrentAccount()
let editor: Editor
let textToolbarElement: HTMLElement
let imageToolbarElement: HTMLElement
let placeHolderStr: string = ''
$: ph = translate(placeholder, {}, $themeStore.language).then((r) => {
placeHolderStr = r
})
const dispatch = createEventDispatcher()
$: handler = textEditorCommandHandler(editor)
let editor: Editor | undefined
let collaborativeEditor: CollaborativeTextEditor
export function commands (): TextEditorCommandHandler | undefined {
return handler
return collaborativeEditor?.commands()
}
export function getHTML (): string | undefined {
if (editor !== undefined) {
return editor.getHTML()
}
export function takeSnapshot (snapshotId: string): void {
collaborativeEditor?.takeSnapshot(snapshotId)
}
export function copyField (srcFieldId: string, dstFieldId: string): void {
collaborativeEditor?.copyField(srcFieldId, dstFieldId)
}
// TODO Not collaborative
export function getNodeElement (uuid: string): Element | null {
if (editor === undefined || uuid === '') {
return null
@ -122,6 +73,7 @@
return editor.view.dom.querySelector(nodeElementQuerySelector(uuid))
}
// TODO Not collaborative
export function selectNode (uuid: string): void {
if (editor === undefined) {
return
@ -152,11 +104,12 @@
}
const [$start, $end] = [doc.resolve(range.from), doc.resolve(range.to)]
editor.view.dispatch(tr.setSelection(new TextSelection($start, $end)))
needFocus = true
editor?.view.dispatch(tr.setSelection(new TextSelection($start, $end)))
focus()
})
}
// TODO Not collaborative
export function selectRange (from: number, to: number): void {
if (editor === undefined) {
return
@ -165,9 +118,10 @@
const { doc, tr } = editor.view.state
const [$start, $end] = [doc.resolve(from), doc.resolve(to)]
editor.view.dispatch(tr.setSelection(new TextSelection($start, $end)))
needFocus = true
focus()
}
// TODO Not collaborative
export function setNodeUuid (nodeId: string): boolean {
if (editor === undefined || editor.view.state.selection.empty || nodeId === '') {
return false
@ -176,165 +130,21 @@
return editor.chain().setNodeUuid(nodeId).run()
}
export function takeSnapshot (snapshotId: string): void {
copyDocumentContent(documentId, snapshotId, { provider }, initialContentId)
}
export function copyField (srcFieldId: string, dstFieldId: string): void {
copyDocumentField(documentId, srcFieldId, dstFieldId, { provider }, initialContentId)
}
let needFocus = false
let focused = false
let posFocus: FocusPosition | undefined = undefined
export function focus (position?: FocusPosition): void {
posFocus = position
needFocus = true
collaborativeEditor?.focus(position)
}
$: if (editor !== undefined && needFocus) {
if (!focused) {
editor.commands.focus(posFocus)
posFocus = undefined
}
needFocus = false
export function isFocused (): boolean {
return collaborativeEditor?.isFocused() ?? false
}
$: if (editor !== undefined) {
editor.setEditable(!readonly)
}
$: isStyleToolbarSupported = (!readonly || textNodeActions.length > 0) && canShowPopups
$: tippyOptions = {
zIndex: 100000,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary,
padding: 8,
altAxis: true,
tether: false
}
}
]
}
}
const optionalExtensions: AnyExtension[] = []
if (attachFile !== undefined) {
optionalExtensions.push(
ImageExtension.configure({
inline: true,
attachFile
})
)
}
onMount(() => {
void ph.then(() => {
editor = new Editor({
element,
editable: true,
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) },
extensions: [
...defaultExtensions,
...optionalExtensions,
Placeholder.configure({ placeholder: placeHolderStr }),
InlineStyleToolbarExtension.configure({
tippyOptions,
element: textToolbarElement,
isSupported: () => isStyleToolbarSupported,
isSelectionOnly: () => false
}),
InlinePopupExtension.configure({
pluginKey: 'show-image-actions-popup',
element: imageToolbarElement,
tippyOptions: {
...tippyOptions,
appendTo: () => boundary ?? element
},
shouldShow: () => {
if (!visible || readonly || !canShowPopups) {
return false
}
return editor?.isActive('image')
}
}),
Collaboration.configure({
document: ydoc,
field
}),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.email,
color: getPlatformColorForText(currentUser.email, $themeStore.dark)
},
selectionRender: noSelectionRender
}),
Completion.configure({
...completionConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}
}),
...onExtensions()
],
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
},
onBlur: ({ event }) => {
focused = false
dispatch('blur', event)
},
onFocus: () => {
focused = true
updateFocus()
dispatch('focus')
},
onUpdate: ({ transaction }) => {
// ignore non-document changes
if (!transaction.docChanged) return
// ignore non-local changes
if (isChangeOrigin(transaction)) return
dispatch('update')
}
})
if (initialContent !== undefined) {
editor.commands.insertContent(initialContent)
}
})
})
onDestroy(() => {
if (editor !== undefined) {
try {
editor.destroy()
} catch (err: any) {}
if (contextProvider === undefined) {
provider.destroy()
}
}
})
export let focusIndex = -1
const { idx, focusManager } = registerFocus(focusIndex, {
focus: () => {
if (visible) {
focus()
}
return visible && element !== null
focus()
return element !== null
},
isFocus: () => document.activeElement === element,
isFocus: () => isFocused(),
canBlur: () => false
})
const updateFocus = (): void => {
@ -342,106 +152,41 @@
focusManager?.setFocus(idx)
}
}
$: if (element !== undefined) {
element.addEventListener('focus', updateFocus, { once: true })
}
function handleFocus (): void {
needFocus = true
updateFocus()
}
</script>
<slot {editor} />
{#if loading}
<div class="flex p-3">
<Loading />
</div>
{/if}
{#if visible}
{#if $$slots.tools}
<div class="ref-container" style:overflow>
<div class="text-editor-toolbar buttons-group xsmall-gap">
<slot name="tools" />
</div>
</div>
{/if}
<div
class="text-editor-toolbar buttons-group xsmall-gap mb-4"
bind:this={textToolbarElement}
style="visibility: hidden;"
>
{#if isStyleToolbarSupported}
<TextEditorStyleToolbar
textEditor={editor}
textFormatCategories={readonly
? []
: [
TextFormatCategory.Heading,
TextFormatCategory.TextDecoration,
TextFormatCategory.Link,
TextFormatCategory.List,
TextFormatCategory.Quote,
TextFormatCategory.Code,
TextFormatCategory.Table
]}
formatButtonSize={buttonSize}
{textNodeActions}
on:focus={() => {
needFocus = true
}}
on:action={(event) => {
dispatch('action', event.detail)
needFocus = true
}}
/>
{/if}
</div>
<div
class="text-editor-toolbar buttons-group xsmall-gap mb-4"
bind:this={imageToolbarElement}
style="visibility: hidden;"
>
<ImageStyleToolbar textEditor={editor} formatButtonSize={buttonSize} on:focus={handleFocus} />
</div>
<div class="text-input" style:overflow class:focusable class:hidden={loading}>
<div class="select-text" style="width: 100%;" bind:this={element} />
</div>
{/if}
<div class="root">
<CollaborativeTextEditor
bind:this={collaborativeEditor}
{documentId}
{field}
{initialContentId}
{targetContentId}
{readonly}
{buttonSize}
{placeholder}
{overflow}
{boundary}
{attachFile}
extensions={[...onExtensions()]}
{textNodeActions}
{canShowPopups}
{editorAttributes}
on:update
on:open-document
on:blur
on:focus={handleFocus}
on:editor={(evt) => {
editor = evt.detail
}}
/>
</div>
<style lang="scss">
.ref-container .text-editor-toolbar {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--button-shadow);
z-index: 1;
}
.ref-container:focus-within .text-editor-toolbar {
position: sticky;
top: 1.25rem;
}
.text-editor-toolbar {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--theme-popup-shadow);
z-index: 1;
}
.text-input {
.root {
font-size: 0.9375rem;
}
.hidden {
display: none;
}
</style>

View File

@ -13,8 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Asset, IntlString, getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Asset, IntlString } from '@hcengineering/platform'
import {
AnySvelteComponent,
Button,
@ -27,13 +26,13 @@
import { createEventDispatcher } from 'svelte'
import { Completion } from '../Completion'
import textEditorPlugin from '../plugin'
import { RefAction, RefInputActionItem, TextEditorHandler, TextFormatCategory } from '../types'
import { RefAction, TextEditorHandler, TextFormatCategory } from '../types'
import TextEditor from './TextEditor.svelte'
import { defaultRefActions, getModelRefActions } from './editor/actions'
import { completionConfig } from './extensions'
import { EmojiExtension } from './extension/emoji'
import { IsEmptyContentExtension } from './extension/isEmptyContent'
import Send from './icons/Send.svelte'
import { generateDefaultActions } from './editor/actions'
export let content: string = ''
export let showHeader = false
@ -49,7 +48,6 @@
export let focusable: boolean = false
export let boundary: HTMLElement | undefined = undefined
const client = getClient()
const dispatch = createEventDispatcher()
const buttonSize = 'medium'
@ -77,20 +75,10 @@
}
}
let actions: RefAction[] = generateDefaultActions(editorHandler)
.concat(...extraActions)
.sort((a, b) => a.order - b.order)
client.findAll<RefInputActionItem>(textEditorPlugin.class.RefInputActionItem, {}).then(async (res) => {
const cont: RefAction[] = []
for (const r of res) {
cont.push({
label: r.label,
icon: r.icon,
order: r.order ?? 10000,
action: await getResource(r.action)
})
}
actions = actions.concat(...cont).sort((a, b) => a.order - b.order)
let actions: RefAction[] = defaultRefActions.concat(...extraActions).sort((a, b) => a.order - b.order)
void getModelRefActions().then((modelActions) => {
actions = actions.concat(...modelActions).sort((a, b) => a.order - b.order)
})
export function submit (): void {

View File

@ -16,12 +16,11 @@
import { createEventDispatcher } from 'svelte'
import { AnyExtension, mergeAttributes } from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { getResource, IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { IntlString } from '@hcengineering/platform'
import { Button, ButtonSize, Scroller } from '@hcengineering/ui'
import textEditorPlugin from '../plugin'
import { RefAction, RefInputActionItem, TextEditorHandler, TextFormatCategory } from '../types'
import { generateDefaultActions } from './editor/actions'
import { RefAction, TextEditorHandler, TextFormatCategory } from '../types'
import { defaultRefActions, getModelRefActions } from './editor/actions'
import TextEditor from './TextEditor.svelte'
const dispatch = createEventDispatcher()
@ -84,7 +83,6 @@
? 'max-content'
: maxHeight
const client = getClient()
const editorHandler: TextEditorHandler = {
insertText: (text) => {
textEditor?.insertText(text)
@ -97,20 +95,10 @@
textEditor?.focus()
}
}
let actions: RefAction[] = generateDefaultActions(editorHandler)
.concat(...extraActions)
.sort((a, b) => a.order - b.order)
client.findAll<RefInputActionItem>(textEditorPlugin.class.RefInputActionItem, {}).then(async (res) => {
const cont: RefAction[] = []
for (const r of res) {
cont.push({
label: r.label,
icon: r.icon,
order: r.order ?? 10000,
action: await getResource(r.action)
})
}
actions = actions.concat(...cont).sort((a, b) => a.order - b.order)
let actions: RefAction[] = defaultRefActions.concat(...extraActions).sort((a, b) => a.order - b.order)
void getModelRefActions().then((modelActions) => {
actions = actions.concat(...modelActions).sort((a, b) => a.order - b.order)
})
const mergedEditorAttributes = mergeAttributes(

View File

@ -17,7 +17,7 @@
import { IntlString, translate } from '@hcengineering/platform'
import { themeStore } from '@hcengineering/ui'
import { AnyExtension, Editor, Extension, FocusPosition, mergeAttributes } from '@tiptap/core'
import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
import Placeholder from '@tiptap/extension-placeholder'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
@ -30,6 +30,7 @@
import { defaultEditorAttributes } from './editor/editorProps'
import { InlinePopupExtension } from './extension/inlinePopup'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import { SubmitExtension } from './extension/submit'
import { defaultExtensions } from './extensions'
export let content: string = ''
@ -114,38 +115,7 @@
needFocus = false
}
const Handle = Extension.create({
addKeyboardShortcuts () {
return {
'Ctrl-Enter': () => {
const res = this.editor.commands.splitListItem('listItem')
if (!res) {
this.editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock()
])
}
return true
},
'Shift-Enter': () => {
this.editor.commands.setHardBreak()
return true
},
Enter: () => {
submit()
return true
},
Space: () => {
if (editor.isActive('link')) {
this.editor.commands.toggleMark('link')
}
return false
}
}
}
})
const Handle = SubmitExtension.configure({ submit })
onMount(() => {
void ph.then(() => {
@ -171,7 +141,7 @@
...tippyOptions,
appendTo: () => boundary ?? element
},
shouldShow: () => editor?.isActive('image')
shouldShow: ({ editor }) => editor.isEditable && editor.isActive('image')
})
],
parseOptions: {
@ -217,31 +187,24 @@
deleteOp(n, pos)
})
}
function handleFocus (): void {
needFocus = true
}
</script>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={textToolbarElement}>
<TextEditorStyleToolbar
textEditor={editor}
{textFormatCategories}
on:focus={() => {
needFocus = true
}}
/>
<div bind:this={textToolbarElement} class="text-editor-toolbar buttons-group xsmall-gap mb-4">
<TextEditorStyleToolbar textEditor={editor} {textFormatCategories} on:focus={handleFocus} />
</div>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={imageToolbarElement}>
<ImageStyleToolbar
textEditor={editor}
on:focus={() => {
needFocus = true
}}
/>
<div bind:this={imageToolbarElement} class="text-editor-toolbar buttons-group xsmall-gap mb-4">
<ImageStyleToolbar textEditor={editor} on:focus={handleFocus} />
</div>
<div class="select-text" style="width: 100%;" bind:this={element} />
<style lang="scss">
.formatPanel {
.text-editor-toolbar {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
background-color: var(--theme-comp-header-color);

View File

@ -338,8 +338,8 @@
selected={false}
disabled={textEditor.view.state.selection.empty}
showTooltip={{ label: action.label }}
on:click={async () => {
dispatch('action', { action: action.id, editor: textEditor })
on:click={() => {
void action.action({ editor: textEditor })
}}
/>
{/each}

View File

@ -1,39 +1,57 @@
import { getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { EmojiPopup, IconEmoji, showPopup } from '@hcengineering/ui'
import RiMention from '../icons/RIMention.svelte'
import textEditorPlugin from '../../plugin'
import { type RefAction, type TextEditorHandler } from '../../types'
import type { RefAction } from '../../types'
export const generateDefaultActions = (editorHandler: TextEditorHandler): RefAction[] => {
return [
{
label: textEditorPlugin.string.Mention,
icon: RiMention,
action: () => {
editorHandler.insertText('@')
editorHandler.focus()
},
order: 3000
export const defaultRefActions: RefAction[] = [
{
label: textEditorPlugin.string.Mention,
icon: RiMention,
action: (_element, editorHandler) => {
editorHandler.insertText('@')
editorHandler.focus()
},
{
label: textEditorPlugin.string.Emoji,
icon: IconEmoji,
action: (element) => {
showPopup(
EmojiPopup,
{},
element,
(emoji) => {
if (emoji === null || emoji === undefined) {
return
}
order: 3000
},
{
label: textEditorPlugin.string.Emoji,
icon: IconEmoji,
action: (element, editorHandler) => {
showPopup(
EmojiPopup,
{},
element,
(emoji) => {
if (emoji === null || emoji === undefined) {
return
}
editorHandler.insertText(emoji)
editorHandler.focus()
},
() => {}
)
},
order: 4001
}
]
editorHandler.insertText(emoji)
editorHandler.focus()
},
() => {}
)
},
order: 4001
}
]
export async function getModelRefActions (): Promise<RefAction[]> {
const client = getClient()
const actions: RefAction[] = []
const items = await client.findAll(textEditorPlugin.class.RefInputActionItem, {})
for (const item of items) {
actions.push({
label: item.label,
icon: item.icon,
order: item.order ?? 10000,
action: await getResource(item.action)
})
}
return actions
}

View File

@ -0,0 +1,38 @@
import { Extension } from '@tiptap/core'
export interface SubmitOptions {
submit: () => void
}
export const SubmitExtension = Extension.create<SubmitOptions>({
addKeyboardShortcuts () {
return {
'Ctrl-Enter': () => {
const res = this.editor.commands.splitListItem('listItem')
if (!res) {
this.editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock()
])
}
return true
},
'Shift-Enter': () => {
this.editor.commands.setHardBreak()
return true
},
Enter: () => {
this.options.submit()
return true
},
Space: () => {
if (this.editor.isActive('link')) {
this.editor.commands.toggleMark('link')
}
return false
}
}
}
})

View File

@ -19,6 +19,9 @@ import { textEditorId } from './plugin'
export * from '@hcengineering/presentation/src/types'
export { default as Collaboration } from './components/Collaboration.svelte'
export { default as CollaborationDiffViewer } from './components/CollaborationDiffViewer.svelte'
export { default as CollaborativeAttributeBox } from './components/CollaborativeAttributeBox.svelte'
export { default as CollaborativeAttributeSectionBox } from './components/CollaborativeAttributeSectionBox.svelte'
export { default as CollaborativeTextEditor } from './components/CollaborativeTextEditor.svelte'
export { default as CollaboratorEditor } from './components/CollaboratorEditor.svelte'
export { default as FullDescriptionBox } from './components/FullDescriptionBox.svelte'
export { default as MarkupDiffViewer } from './components/MarkupDiffViewer.svelte'
@ -65,7 +68,14 @@ export {
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
export { TodoItemExtension, TodoListExtension } from './components/extension/todo'
export { TiptapCollabProvider, type TiptapCollabProviderConfiguration, createTiptapCollaborationData } from './provider'
export {
TiptapCollabProvider,
type TiptapCollabProviderConfiguration,
createTiptapCollaborationData,
minioDocumentId,
mongodbDocumentId,
platformDocumentId
} from './provider'
export { CollaborationIds } from './types'
export { textEditorId }

View File

@ -13,23 +13,72 @@
// limitations under the License.
//
import { Doc as Ydoc } from 'yjs'
import type { Doc, Ref } from '@hcengineering/core'
import { type KeyedAttribute, getClient } from '@hcengineering/presentation'
import { HocuspocusProvider, type HocuspocusProviderConfiguration } from '@hocuspocus/provider'
export type TiptapCollabProviderConfiguration = HocuspocusProviderConfiguration &
Required<Pick<HocuspocusProviderConfiguration, 'token'>>
Required<Pick<HocuspocusProviderConfiguration, 'token'>> &
Omit<HocuspocusProviderConfiguration, 'parameters'> & {
parameters: TiptapCollabProviderURLParameters
}
export interface TiptapCollabProviderURLParameters {
initialContentId?: DocumentId
targetContentId?: DocumentId
}
export type DocumentId = string
export function minioDocumentId (docId: Ref<Doc>, attr?: KeyedAttribute): DocumentId {
return attr !== undefined ? `minio://${docId}%${attr.key}` : `minio://${docId}`
}
export function platformDocumentId (docId: Ref<Doc>, attr: KeyedAttribute): DocumentId {
return `platform://${attr.attr.attributeOf}/${docId}/${attr.key}`
}
export function mongodbDocumentId (docId: Ref<Doc>, attr: KeyedAttribute): DocumentId {
const domain = getClient().getHierarchy().getDomain(attr.attr.attributeOf)
return `mongodb://${domain}/${docId}/${attr.key}`
}
export class TiptapCollabProvider extends HocuspocusProvider {
loaded: Promise<void>
constructor (configuration: TiptapCollabProviderConfiguration) {
super(configuration as HocuspocusProviderConfiguration)
const parameters: Record<string, any> = {}
const initialContentId = configuration.parameters?.initialContentId
if (initialContentId !== undefined && initialContentId !== '') {
parameters.initialContentId = initialContentId
}
const targetContentId = configuration.parameters?.targetContentId
if (targetContentId !== undefined && targetContentId !== '') {
parameters.targetContentId = targetContentId
}
const hocuspocusConfig: HocuspocusProviderConfiguration = {
...configuration,
parameters
}
super(hocuspocusConfig)
this.loaded = new Promise((resolve) => {
this.on('synced', resolve)
})
}
copyContent (sourceId: string, targetId: string): void {
setContent (field: string, content: string): void {
const payload = {
action: 'document.content',
params: { field, content }
}
this.sendStateless(JSON.stringify(payload))
}
copyContent (sourceId: DocumentId, targetId: DocumentId): void {
const payload = {
action: 'document.copy',
params: { sourceId, targetId }
@ -37,7 +86,7 @@ export class TiptapCollabProvider extends HocuspocusProvider {
this.sendStateless(JSON.stringify(payload))
}
copyField (documentId: string, srcFieldId: string, dstFieldId: string): void {
copyField (documentId: DocumentId, srcFieldId: string, dstFieldId: string): void {
const payload = {
action: 'document.field.copy',
params: { documentId, srcFieldId, dstFieldId }
@ -53,8 +102,9 @@ export class TiptapCollabProvider extends HocuspocusProvider {
export const createTiptapCollaborationData = (params: {
collaboratorURL: string
documentId: string
initialContentId: string | undefined
documentId: DocumentId
initialContentId: DocumentId | undefined
targetContentId: DocumentId | undefined
token: string
}): { provider: TiptapCollabProvider, ydoc: Ydoc } => {
const ydoc: Ydoc = new Ydoc()
@ -66,7 +116,8 @@ export const createTiptapCollaborationData = (params: {
document: ydoc,
token: params.token,
parameters: {
initialContentId: params.initialContentId ?? ''
initialContentId: params.initialContentId,
targetContentId: params.targetContentId
}
})
}

View File

@ -63,6 +63,7 @@ export interface TextNodeAction {
id: string
label?: IntlString
icon: Asset | AnySvelteComponent
action: (params: { editor: Editor }) => Promise<void> | void
}
/**

View File

@ -17,7 +17,7 @@ import { type onStatelessParameters } from '@hocuspocus/provider'
import { type Attribute } from '@tiptap/core'
import * as Y from 'yjs'
import { TiptapCollabProvider } from './provider'
import { type DocumentId, TiptapCollabProvider } from './provider'
type ProviderData = (
| {
@ -29,7 +29,12 @@ type ProviderData = (
}
) & { ydoc?: Y.Doc }
function getProvider (documentId: string, providerData: ProviderData, initialContentId?: string): TiptapCollabProvider {
function getProvider (
documentId: DocumentId,
providerData: ProviderData,
initialContentId?: DocumentId,
targetContentId?: DocumentId
): TiptapCollabProvider {
if (!('provider' in providerData)) {
const provider = new TiptapCollabProvider({
url: providerData.collaboratorURL,
@ -37,7 +42,8 @@ function getProvider (documentId: string, providerData: ProviderData, initialCon
document: providerData.ydoc ?? new Y.Doc(),
token: providerData.token,
parameters: {
initialContentId: initialContentId ?? ''
initialContentId,
targetContentId
},
onStateless (data: onStatelessParameters) {
try {
@ -58,21 +64,21 @@ function getProvider (documentId: string, providerData: ProviderData, initialCon
}
export function copyDocumentField (
documentId: string,
documentId: DocumentId,
srcFieldId: string,
dstFieldId: string,
providerData: ProviderData,
initialContentId?: string
initialContentId?: DocumentId
): void {
const provider = getProvider(documentId, providerData, initialContentId)
provider.copyField(documentId, srcFieldId, dstFieldId)
}
export function copyDocumentContent (
documentId: string,
documentId: DocumentId,
snapshotId: string,
providerData: ProviderData,
initialContentId?: string
initialContentId?: DocumentId
): void {
const provider = getProvider(documentId, providerData, initialContentId)
provider.copyContent(documentId, snapshotId)

View File

@ -28,7 +28,9 @@ export function getHTML (node: ProseMirrorNode, extensions: Extensions): string
/**
* @public
*/
export function parseHTML (content: string, extensions: Extensions): ProseMirrorNode {
export function parseHTML (content: string, extensions?: Extensions): ProseMirrorNode {
extensions = extensions ?? defaultExtensions
const schema = getSchema(extensions)
const json = generateJSON(content, extensions)

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
// 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
@ -13,59 +13,36 @@
// limitations under the License.
-->
<script lang="ts">
import { Asset, IntlString } from '@hcengineering/platform'
import type { Asset, IntlString } from '@hcengineering/platform'
import type { AnySvelteComponent } from '../types'
import Label from './Label.svelte'
import ArrowUp from './icons/Up.svelte'
import ArrowDown from './icons/Down.svelte'
import Icon from './Icon.svelte'
export let icon: Asset | AnySvelteComponent
import Icon from './Icon.svelte'
import Label from './Label.svelte'
export let label: IntlString
export let closed: boolean = false
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let showHeader: boolean = true
export let high: boolean = false
export let invisible: boolean = false
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex-row-center section-container"
on:click|preventDefault={() => {
closed = !closed
}}
>
<Icon {icon} size={'small'} />
<!-- <svelte:component this={icon} size={'small'} /> -->
<div class="title"><Label {label} /></div>
<div class="arrow">
{#if closed}<ArrowUp size={'small'} />{:else}<ArrowDown size={'small'} />{/if}
</div>
<div class="antiSection">
{#if showHeader}
<div class="antiSection-header" class:high class:invisible>
{#if icon}
<div class="antiSection-header__icon">
<Icon {icon} size={'small'} />
</div>
{/if}
<span class="antiSection-header__title flex-row-center">
<Label {label} />
</span>
<slot name="header" />
</div>
{/if}
<slot name="content" />
</div>
{#if !closed}<div class="section-content"><slot /></div>{/if}
<style lang="scss">
.section-container {
width: 100%;
height: 5rem;
min-height: 5rem;
cursor: pointer;
user-select: none;
.title {
flex-grow: 1;
margin-left: 0.75rem;
font-weight: 500;
color: var(--caption-color);
}
.arrow {
margin: 0.5rem;
}
}
.section-content {
margin: 1rem 0 3.5rem;
height: auto;
}
:global(.section-container + .section-container),
:global(.section-content + .section-container) {
border-top: 1px solid var(--divider-color);
}
</style>

View File

@ -1,7 +1,7 @@
{
"string": {
"Activity": "Активность",
"Added": "Добавила(а)",
"Added": "Добавил(а)",
"All": "Все",
"AllActivity": "Вся активнось",
"Attributes": "Атрибуты",

View File

@ -51,6 +51,7 @@ const valueTypes: ReadonlyArray<Ref<Class<Doc>>> = [
core.class.TypeNumber,
core.class.TypeDate,
core.class.TypeMarkup,
core.class.TypeCollaborativeMarkup,
core.class.TypeHyperlink
]

View File

@ -32,7 +32,10 @@
$: isTextType = getIsTextType(attributeModel)
function getIsTextType (attributeModel: AttributeModel): boolean {
return attributeModel.attribute?.type?._class === core.class.TypeMarkup
return (
attributeModel.attribute?.type?._class === core.class.TypeMarkup ||
attributeModel.attribute?.type?._class === core.class.TypeCollaborativeMarkup
)
}
let isDiffShown = false

View File

@ -33,7 +33,8 @@ const valueTypes: ReadonlyArray<Ref<Class<Doc>>> = [
core.class.EnumOf,
core.class.TypeNumber,
core.class.TypeDate,
core.class.TypeMarkup
core.class.TypeMarkup,
core.class.TypeCollaborativeMarkup
]
export type TxDisplayViewlet =

View File

@ -0,0 +1,255 @@
<!--
// 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 attachment, { Attachment } from '@hcengineering/attachment'
import { Account, Doc, Ref, generateId } from '@hcengineering/core'
import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { KeyedAttribute, createQuery, getClient } from '@hcengineering/presentation'
import textEditor, { AttachIcon, CollaborativeAttributeBox, RefAction } from '@hcengineering/text-editor'
import { navigate } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getObjectLinkFragment } from '@hcengineering/view-resources'
import AttachmentsGrid from './AttachmentsGrid.svelte'
import { uploadFile } from '../utils'
import { defaultRefActions, getModelRefActions } from '@hcengineering/text-editor/src/components/editor/actions'
export let object: Doc
export let key: KeyedAttribute
export let placeholder: IntlString
export let focusIndex = -1
export let boundary: HTMLElement | undefined = undefined
export let refContainer: HTMLElement | undefined = undefined
export let enableAttachments: boolean = true
export let useAttachmentPreview: boolean = false
const client = getClient()
let refActions: RefAction[] = []
let extraActions: RefAction[] = []
let modelRefActions: RefAction[] = []
$: if (enableAttachments) {
extraActions = [
{
label: textEditor.string.Attach,
icon: AttachIcon,
action: handleAttach,
order: 1001
}
]
} else {
extraActions = []
}
void getModelRefActions().then((actions) => {
modelRefActions = actions
})
$: refActions = defaultRefActions
.concat(extraActions)
.concat(modelRefActions)
.sort((a, b) => a.order - b.order)
let progress = false
let attachments: Attachment[] = []
const query = createQuery()
$: query.query(
attachment.class.Attachment,
{
attachedTo: object._id
},
(res) => {
attachments = res
}
)
let inputFile: HTMLInputElement
export function handleAttach (): void {
inputFile.click()
}
async function fileSelected (): Promise<void> {
progress = true
const list = inputFile.files
if (list === null || list.length === 0) return
for (let index = 0; index < list.length; index++) {
const file = list.item(index)
if (file !== null) {
await createAttachment(file)
}
}
inputFile.value = ''
progress = false
}
async function createAttachment (file: File): Promise<{ file: string, type: string } | undefined> {
try {
const uuid = await uploadFile(file)
const _id: Ref<Attachment> = generateId()
const attachmentDoc: Attachment = {
_id,
_class: attachment.class.Attachment,
collection: 'attachments',
modifiedOn: 0,
modifiedBy: '' as Ref<Account>,
space: object.space,
attachedTo: object._id,
attachedToClass: object._class,
name: file.name,
file: uuid,
type: file.type,
size: file.size,
lastModified: file.lastModified
}
await client.addCollection(
attachment.class.Attachment,
object.space,
object._id,
object._class,
'attachments',
attachmentDoc,
attachmentDoc._id
)
return { file: uuid, type: file.type }
} catch (err: any) {
await setPlatformStatus(unknownError(err))
}
}
function isAllowedPaste (evt: ClipboardEvent): boolean {
let t: HTMLElement | null = evt.target as HTMLElement
if (refContainer === undefined) {
return true
}
while (t != null) {
t = t.parentElement
if (t === refContainer) {
return true
}
}
return false
}
export async function pasteAction (evt: ClipboardEvent): Promise<void> {
if (!isAllowedPaste(evt)) {
return
}
const items = evt.clipboardData?.items ?? []
for (const index in items) {
const item = items[index]
if (item.kind === 'file') {
const blob = item.getAsFile()
if (blob !== null) {
await createAttachment(blob)
}
}
}
}
export async function fileDrop (e: DragEvent): Promise<void> {
progress = true
const list = e.dataTransfer?.files
if (list !== undefined && list.length !== 0) {
for (let index = 0; index < list.length; index++) {
const file = list.item(index)
if (file !== null) {
await createAttachment(file)
}
}
}
progress = false
}
async function removeAttachment (attachment: Attachment): Promise<void> {
progressItems.push(attachment._id)
progressItems = progressItems
await client.removeCollection(
attachment._class,
attachment.space,
attachment._id,
attachment.attachedTo,
attachment.attachedToClass,
'attachments'
)
}
let progressItems: Ref<Doc>[] = []
</script>
<input
bind:this={inputFile}
multiple
type="file"
name="file"
id="fileInput"
style="display: none"
on:change={fileSelected}
/>
{#key object?._id}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex-col clear-mins"
on:paste={(ev) => pasteAction(ev)}
on:dragover|preventDefault={() => {}}
on:dragleave={() => {}}
on:drop|preventDefault|stopPropagation={(ev) => {
void fileDrop(ev)
}}
>
<CollaborativeAttributeBox
{object}
{key}
{focusIndex}
{placeholder}
{boundary}
{refActions}
attachFile={async (file) => {
return await createAttachment(file)
}}
on:open-document={async (event) => {
const doc = await client.findOne(event.detail._class, { _id: event.detail._id })
if (doc != null) {
const location = await getObjectLinkFragment(client.getHierarchy(), doc, {}, view.component.EditDoc)
navigate(location)
}
}}
on:focus
on:blur
on:update
/>
{#if (attachments.length > 0 && enableAttachments) || progress}
<AttachmentsGrid
{attachments}
{progress}
{progressItems}
{useAttachmentPreview}
on:remove={async (evt) => {
if (evt.detail !== undefined) {
await removeAttachment(evt.detail)
}
}}
/>
{/if}
</div>
{/key}

View File

@ -19,12 +19,10 @@
import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation'
import textEditor, { AttachIcon, type RefAction, StyledTextBox } from '@hcengineering/text-editor'
import { ButtonSize, Loading, updatePopup, Scroller } from '@hcengineering/ui'
import { ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { ButtonSize } from '@hcengineering/ui'
import attachment from '../plugin'
import { deleteFile, uploadFile } from '../utils'
import AttachmentPresenter from './AttachmentPresenter.svelte'
import AttachmentPreview from './AttachmentPreview.svelte'
import AttachmentsGrid from './AttachmentsGrid.svelte'
export let objectId: Ref<Doc> | undefined = undefined
export let space: Ref<Space> | undefined = undefined
@ -54,18 +52,6 @@
$: draftKey = objectId ? `${objectId}_attachments` : undefined
const dispatch = createEventDispatcher()
let attachmentPopupId: string = ''
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
const currentAttachmentIndex = listProvider.current()
if (currentAttachmentIndex === undefined) return
const selected = currentAttachmentIndex + offset
const sel = listProvider.docs()[selected] as Attachment
if (sel !== undefined && attachmentPopupId !== '') {
listProvider.updateFocus(sel)
updatePopup(attachmentPopupId, { props: { file: sel.file, name: sel.name, contentType: sel.type } })
}
})
$: listProvider.update(Array.from(attachments.values()).filter((attachment) => attachment.type.startsWith('image/')))
export function focus (): void {
refInput.focus()
@ -358,7 +344,6 @@
extraActions = []
}
let element: HTMLElement
let progressItems: Ref<Doc>[] = []
</script>
@ -382,90 +367,41 @@
fileDrop(ev)
}}
>
<div class="expand-collapse">
<StyledTextBox
{focusIndex}
bind:this={refInput}
bind:content
{placeholder}
{alwaysEdit}
{showButtons}
{buttonSize}
{maxHeight}
{focusable}
{kind}
{enableBackReferences}
{isScrollable}
{boundary}
{extraActions}
on:changeSize
on:changeContent
on:blur
on:focus
on:open-document
attachFile={async (file) => {
return await createAttachment(file)
<StyledTextBox
{focusIndex}
bind:this={refInput}
bind:content
{placeholder}
{alwaysEdit}
{showButtons}
{buttonSize}
{maxHeight}
{focusable}
{kind}
{enableBackReferences}
{isScrollable}
{boundary}
{extraActions}
on:changeSize
on:changeContent
on:blur
on:focus
on:open-document
attachFile={async (file) => {
return await createAttachment(file)
}}
/>
{#if (attachments.size > 0 && enableAttachments) || progress}
<AttachmentsGrid
attachments={Array.from(attachments.values())}
{progress}
{progressItems}
{useAttachmentPreview}
on:remove={async (evt) => {
if (evt.detail !== undefined) {
await removeAttachment(evt.detail)
}
}}
/>
</div>
{#if (attachments.size && enableAttachments) || progress}
<div class="attachment-grid-container">
<Scroller noStretch shrink>
<div class="attachment-grid">
{#each Array.from(attachments.values()) as attachment, index}
{#if useAttachmentPreview}
<AttachmentPreview
value={attachment}
{listProvider}
on:open={(res) => (attachmentPopupId = res.detail)}
/>
{:else}
<AttachmentPresenter
value={attachment}
removable
showPreview
progress={progressItems.includes(attachment._id)}
on:remove={(result) => {
if (result !== undefined) {
removeAttachment(attachment)
}
}}
/>
{/if}
{/each}
{#if progress}
<div class="flex p-3" bind:this={element}>
<Loading
on:progress={() => {
element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' })
}}
/>
</div>
{/if}
</div>
</Scroller>
</div>
{/if}
</div>
<style lang="scss">
.attachment-grid-container {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
padding: 0.5rem;
min-width: 0;
max-height: 21.625rem;
color: var(--theme-caption-color);
background-color: var(--theme-button-default);
border: 1px solid var(--theme-button-border);
border-radius: 0.25rem;
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fit, 17.25rem);
grid-auto-rows: minmax(3rem, auto);
gap: 0.5rem;
}
}
</style>

View File

@ -0,0 +1,93 @@
<!--
// 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 { Attachment } from '@hcengineering/attachment'
import { Doc, Ref } from '@hcengineering/core'
import { Loading, updatePopup, Scroller } from '@hcengineering/ui'
import { ListSelectionProvider } from '@hcengineering/view-resources'
import AttachmentPresenter from './AttachmentPresenter.svelte'
import AttachmentPreview from './AttachmentPreview.svelte'
export let attachments: Attachment[] = []
export let useAttachmentPreview = false
export let progress = false
export let progressItems: Ref<Doc>[] = []
let element: HTMLElement
let attachmentPopupId: string = ''
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0) => {
const currentAttachmentIndex = listProvider.current()
if (currentAttachmentIndex === undefined) return
const selected = currentAttachmentIndex + offset
const sel = listProvider.docs()[selected] as Attachment
if (sel !== undefined && attachmentPopupId !== '') {
listProvider.updateFocus(sel)
updatePopup(attachmentPopupId, { props: { file: sel.file, name: sel.name, contentType: sel.type } })
}
})
$: listProvider.update(attachments.filter((p) => p.type.startsWith('image/')))
</script>
<div class="attachment-grid-container">
<Scroller noStretch shrink>
<div class="attachment-grid">
{#each attachments as attachment (attachment._id)}
{#if useAttachmentPreview}
<AttachmentPreview value={attachment} {listProvider} on:open={(res) => (attachmentPopupId = res.detail)} />
{:else}
<AttachmentPresenter
value={attachment}
removable
showPreview
progress={progressItems.includes(attachment._id)}
on:remove
/>
{/if}
{/each}
{#if progress}
<div class="flex p-3" bind:this={element}>
<Loading
on:progress={() => {
element.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'center' })
}}
/>
</div>
{/if}
</div>
</Scroller>
</div>
<style lang="scss">
.attachment-grid-container {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
padding: 0.5rem;
min-width: 0;
max-height: 21.625rem;
color: var(--theme-caption-color);
background-color: var(--theme-button-default);
border: 1px solid var(--theme-button-border);
border-radius: 0.25rem;
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fit, 17.25rem);
grid-auto-rows: minmax(3rem, auto);
gap: 0.5rem;
}
}
</style>

View File

@ -35,6 +35,7 @@ import FileDownload from './components/icons/FileDownload.svelte'
import Photos from './components/Photos.svelte'
import AttachmentStyledBox from './components/AttachmentStyledBox.svelte'
import AttachmentStyleBoxEditor from './components/AttachmentStyleBoxEditor.svelte'
import AttachmentStyleBoxCollabEditor from './components/AttachmentStyleBoxCollabEditor.svelte'
import AccordionEditor from './components/AccordionEditor.svelte'
import IconUploadDuo from './components/icons/UploadDuo.svelte'
import IconAttachment from './components/icons/Attachment.svelte'
@ -56,6 +57,7 @@ export {
FileBrowser,
AttachmentStyledBox,
AttachmentStyleBoxEditor,
AttachmentStyleBoxCollabEditor,
AccordionEditor,
IconUploadDuo,
IconAttachment,

View File

@ -16,7 +16,7 @@
import { Member } from '@hcengineering/contact'
import type { Class, Doc, Ref, Space } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Button, Icon, IconAdd, Label, showPopup } from '@hcengineering/ui'
import { Button, IconAdd, Label, Section, showPopup } from '@hcengineering/ui'
import { Viewlet, ViewletPreference } from '@hcengineering/view'
import { Table, ViewletSelector, ViewletSettingButton } from '@hcengineering/view-resources'
import contact from '../plugin'
@ -66,14 +66,8 @@
let preference: ViewletPreference | undefined
</script>
<div class="antiSection">
<div class="antiSection-header">
<div class="antiSection-header__icon">
<Icon icon={IconMembersOutline} size={'small'} />
</div>
<span class="antiSection-header__title">
<Label label={contact.string.Members} />
</span>
<Section label={contact.string.Members} icon={IconMembersOutline}>
<svelte:fragment slot="header">
<div class="buttons-group xsmall-gap">
<ViewletSelector
hidden
@ -85,25 +79,28 @@
<ViewletSettingButton kind={'ghost'} bind:viewlet />
<Button id={contact.string.AddMember} icon={IconAdd} kind={'ghost'} on:click={createApp} />
</div>
</div>
{#if members > 0 && viewlet}
<Table
_class={contact.class.Member}
config={preference?.config ?? viewlet.config}
options={viewlet.options}
query={{ attachedTo: objectId }}
loadingProps={{ length: members }}
/>
{:else}
<div class="antiSection-empty solid flex-col mt-3">
<span class="content-dark-color">
<Label label={contact.string.NoMembers} />
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="over-underline content-color" on:click={createApp}>
<Label label={contact.string.AddMember} />
</span>
</div>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="content">
{#if members > 0 && viewlet}
<Table
_class={contact.class.Member}
config={preference?.config ?? viewlet.config}
options={viewlet.options}
query={{ attachedTo: objectId }}
loadingProps={{ length: members }}
/>
{:else}
<div class="antiSection-empty solid flex-col mt-3">
<span class="content-dark-color">
<Label label={contact.string.NoMembers} />
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="over-underline content-color" on:click={createApp}>
<Label label={contact.string.AddMember} />
</span>
</div>
{/if}
</svelte:fragment>
</Section>

View File

@ -15,7 +15,7 @@
<script lang="ts">
import type { Space } from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { Icon, Label } from '@hcengineering/ui'
import { Section } from '@hcengineering/ui'
import plugin from '../plugin'
import IconMembersOutline from './icons/MembersOutline.svelte'
import SpaceMembers from './SpaceMembers.svelte'
@ -24,14 +24,8 @@
export let space: Space
</script>
<div class="antiSection">
<div class="antiSection-header">
<div class="antiSection-header__icon">
<Icon icon={IconMembersOutline} size={'small'} />
</div>
<span class="antiSection-header__title">
<Label {label} />
</span>
</div>
<SpaceMembers {space} />
</div>
<Section {label} icon={IconMembersOutline}>
<svelte:fragment slot="content">
<SpaceMembers {space} />
</svelte:fragment>
</Section>

View File

@ -18,7 +18,7 @@
import { Ref, WithLookup } from '@hcengineering/core'
import { Department, Staff } from '@hcengineering/hr'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Button, IconAdd, Label, Scroller, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import { Button, IconAdd, Label, Scroller, Section, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import { Viewlet, ViewletPreference } from '@hcengineering/view'
import { Table, ViewletSelector, ViewletSettingButton } from '@hcengineering/view-resources'
import hr from '../plugin'
@ -67,11 +67,8 @@
let viewlet: WithLookup<Viewlet> | undefined
</script>
<div class="antiSection">
<div class="antiSection-header">
<span class="antiSection-header__title">
<Label label={hr.string.Members} />
</span>
<Section label={hr.string.Members}>
<svelte:fragment slot="header">
<div class="flex-row-center gap-2 reverse">
<ViewletSelector
hidden
@ -83,26 +80,30 @@
<ViewletSettingButton kind={'ghost'} bind:viewlet />
<Button id={hr.string.AddEmployee} icon={IconAdd} kind={'ghost'} on:click={add} />
</div>
</div>
{#if (value?.members.length ?? 0) > 0}
<Scroller>
<Table
_class={hr.mixin.Staff}
config={preference?.config ?? viewlet?.config ?? []}
options={viewlet?.options}
query={{ department: objectId }}
loadingProps={{ length: value?.members.length ?? 0 }}
/>
</Scroller>
{:else}
<div class="antiSection-empty solid flex-col-center mt-3">
<span class="text-sm content-dark-color">
<Label label={hr.string.NoMembers} />
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span class="text-sm content-color over-underline" on:click={add}>
<Label label={hr.string.AddMember} />
</span>
</div>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="content">
{#if (value?.members.length ?? 0) > 0}
<Scroller>
<Table
_class={hr.mixin.Staff}
config={preference?.config ?? viewlet?.config ?? []}
options={viewlet?.options}
query={{ department: objectId }}
loadingProps={{ length: value?.members.length ?? 0 }}
/>
</Scroller>
{:else}
<div class="antiSection-empty solid flex-col-center mt-3">
<span class="text-sm content-dark-color">
<Label label={hr.string.NoMembers} />
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="text-sm content-color over-underline" on:click={add}>
<Label label={hr.string.AddMember} />
</span>
</div>
{/if}
</svelte:fragment>
</Section>

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import type { Doc, Ref } from '@hcengineering/core'
import { Button, Icon, IconAdd, Label, Scroller, showPopup } from '@hcengineering/ui'
import { Button, IconAdd, Label, Scroller, Section, showPopup } from '@hcengineering/ui'
import { Viewlet, ViewletPreference } from '@hcengineering/view'
import { Table, ViewletsSettingButton } from '@hcengineering/view-resources'
import recruit from '../plugin'
@ -35,14 +35,8 @@
let loading = true
</script>
<div class="antiSection">
<div class="antiSection-header">
<div class="antiSection-header__icon">
<Icon icon={IconApplication} size={'small'} />
</div>
<span class="antiSection-header__title">
<Label label={recruit.string.Applications} />
</span>
<Section label={recruit.string.Applications} icon={IconApplication}>
<svelte:fragment slot="header">
<div class="flex-row-center gap-2 reverse">
<ViewletsSettingButton
viewletQuery={{ _id: recruit.viewlet.VacancyApplicationsEmbeddeed }}
@ -53,28 +47,32 @@
/>
<Button id="appls.add" icon={IconAdd} kind={'ghost'} on:click={createApp} />
</div>
</div>
{#if applications > 0 && viewlet && !loading}
<Scroller horizontal>
<Table
_class={recruit.class.Applicant}
config={preference?.config ?? viewlet.config}
query={{ attachedTo: objectId, ...(viewlet?.baseQuery ?? {}) }}
loadingProps={{ length: applications }}
/>
</Scroller>
{:else}
<div class="antiSection-empty solid flex-col-center mt-3">
<div class="caption-color">
<FileDuo size={'large'} />
</svelte:fragment>
<svelte:fragment slot="content">
{#if applications > 0 && viewlet && !loading}
<Scroller horizontal>
<Table
_class={recruit.class.Applicant}
config={preference?.config ?? viewlet.config}
query={{ attachedTo: objectId, ...(viewlet?.baseQuery ?? {}) }}
loadingProps={{ length: applications }}
/>
</Scroller>
{:else}
<div class="antiSection-empty solid flex-col-center mt-3">
<div class="caption-color">
<FileDuo size={'large'} />
</div>
<span class="content-dark-color">
<Label label={recruit.string.NoApplicationsForTalent} />
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="over-underline content-color" on:click={createApp}>
<Label label={recruit.string.CreateAnApplication} />
</span>
</div>
<span class="content-dark-color">
<Label label={recruit.string.NoApplicationsForTalent} />
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span class="over-underline content-color" on:click={createApp}>
<Label label={recruit.string.CreateAnApplication} />
</span>
</div>
{/if}
</div>
{/if}
</svelte:fragment>
</Section>

View File

@ -14,7 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachmentStyleBoxEditor } from '@hcengineering/attachment-resources'
import { AttachmentStyleBoxCollabEditor } from '@hcengineering/attachment-resources'
import core, { ClassifierKind, Data, Doc, Mixin, Ref } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
@ -44,7 +44,7 @@
const notificationClient = getResource(notification.function.GetNotificationClient).then((res) => res())
onDestroy(async () => {
notificationClient.then((client) => client.read(_id))
void notificationClient.then((client) => client.read(_id))
})
const client = getClient()
@ -56,8 +56,8 @@
if (lastId !== _id) {
const prev = lastId
lastId = _id
if (prev) {
notificationClient.then((client) => client.read(prev))
if (prev !== undefined) {
void notificationClient.then((client) => client.read(prev))
}
query.query(recruit.class.Vacancy, { _id }, (result) => {
object = result[0] as Required<Vacancy>
@ -95,11 +95,11 @@
$: getMixins(object, showAllMixins)
let descriptionBox: AttachmentStyleBoxEditor
let descriptionBox: AttachmentStyleBoxCollabEditor
$: descriptionKey = client.getHierarchy().getAttribute(recruit.class.Vacancy, 'fullDescription')
let saved = false
async function save () {
if (!object) {
async function save (): Promise<void> {
if (object === undefined) {
return
}
@ -178,7 +178,7 @@
<!-- <EditBox bind:value={object.description} placeholder={recruit.string.VacancyDescription} focusable on:blur={save} /> -->
<div class="w-full mt-6">
<AttachmentStyleBoxEditor
<AttachmentStyleBoxCollabEditor
focusIndex={30}
{object}
key={{ key: 'fullDescription', attr: descriptionKey }}

View File

@ -1,5 +1,5 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
// Copyright © 2022, 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
@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachmentStyleBoxEditor } from '@hcengineering/attachment-resources'
import { AttachmentStyleBoxCollabEditor } from '@hcengineering/attachment-resources'
import { Class, Doc, Ref, WithLookup } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
@ -67,26 +67,26 @@
let currentProject: Project | undefined
let title = ''
let innerWidth: number
let descriptionBox: AttachmentStyleBoxEditor
let descriptionBox: AttachmentStyleBoxCollabEditor
let showAllMixins: boolean
const notificationClient = getResource(notification.function.GetNotificationClient).then((res) => res())
$: read(_id)
function read (_id: Ref<Doc>) {
function read (_id: Ref<Doc>): void {
if (lastId !== _id) {
const prev = lastId
lastId = _id
notificationClient.then((client) => client.read(prev))
void notificationClient.then((client) => client.read(prev))
}
}
onDestroy(async () => {
notificationClient.then((client) => client.read(_id))
void notificationClient.then((client) => client.read(_id))
})
$: _id &&
_class &&
$: _id !== undefined &&
_class !== undefined &&
queryClient.query<Issue>(
_class,
{ _id },
@ -95,7 +95,7 @@
await save()
}
;[issue] = result
if (issue) {
if (issue !== undefined) {
title = issue.title
currentProject = issue.$lookup?.space
}
@ -103,13 +103,13 @@
{ lookup: { attachedTo: tracker.class.Issue, space: tracker.class.Project } }
)
$: issueId = currentProject && issue && getIssueId(currentProject, issue)
$: issueId = currentProject !== undefined && issue !== undefined && getIssueId(currentProject, issue)
$: canSave = title.trim().length > 0
$: parentIssue = issue?.$lookup?.attachedTo
let saved = false
async function save () {
if (!issue || !canSave) {
async function save (): Promise<void> {
if (issue === undefined || !canSave) {
return
}
@ -121,7 +121,7 @@
}
function showMenu (ev?: Event): void {
if (issue) {
if (issue !== undefined) {
showPopup(
ContextMenu,
{ object: issue, excludedActions: [view.action.Open] },
@ -153,7 +153,7 @@
const clazz = hierarchy.getClass(_class)
const editorMixin = hierarchy.as(clazz, view.mixin.ObjectEditorFooter)
if (editorMixin?.editor == null && clazz.extends != null) return getEditorFooter(clazz.extends)
if (editorMixin.editor) {
if (editorMixin.editor !== undefined) {
return { footer: editorMixin.editor, props: editorMixin?.props }
}
return undefined
@ -254,7 +254,7 @@
on:blur={save}
/>
<div class="w-full mt-6">
<AttachmentStyleBoxEditor
<AttachmentStyleBoxCollabEditor
focusIndex={30}
object={issue}
key={{ key: 'description', attr: descriptionKey }}
@ -266,9 +266,8 @@
}}
/>
</div>
<div class="mt-6">
{#key issue._id && currentProject !== undefined}
{#key issue._id !== undefined && currentProject !== undefined}
{#if currentProject !== undefined}
<SubIssues focusIndex={50} {issue} shouldSaveDraft />
{/if}
@ -286,7 +285,7 @@
</span>
<svelte:fragment slot="custom-attributes">
{#if issue && currentProject}
{#if issue !== undefined && currentProject}
<div class="space-divider" />
<ControlPanel {issue} {showAllMixins} />
{/if}

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachmentStyleBoxEditor } from '@hcengineering/attachment-resources'
import { AttachmentStyleBoxCollabEditor } from '@hcengineering/attachment-resources'
import { Class, Doc, Ref, WithLookup } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
@ -45,25 +45,25 @@
let title = ''
let innerWidth: number
let descriptionBox: AttachmentStyleBoxEditor
let descriptionBox: AttachmentStyleBoxCollabEditor
const notificationClient = getResource(notification.function.GetNotificationClient).then((res) => res())
$: read(_id)
function read (_id: Ref<Doc>) {
function read (_id: Ref<Doc>): void {
if (lastId !== _id) {
const prev = lastId
lastId = _id
notificationClient.then((client) => client.read(prev))
void notificationClient.then((client) => client.read(prev))
}
}
onDestroy(async () => {
notificationClient.then((client) => client.read(_id))
void notificationClient.then((client) => client.read(_id))
})
$: _id &&
_class &&
$: _id !== undefined &&
_class !== undefined &&
query.query(
_class,
{ _id },
@ -79,8 +79,8 @@
let saved = false
async function save () {
if (!template || !canSave) {
async function save (): Promise<void> {
if (template === undefined || !canSave) {
return
}
@ -92,7 +92,7 @@
}
function showMenu (ev?: Event): void {
if (template) {
if (template !== undefined) {
showPopup(
ContextMenu,
{ object: template, excludedActions: [view.action.Open] },
@ -151,7 +151,7 @@
>
<EditBox bind:value={title} placeholder={tracker.string.IssueTitlePlaceholder} kind="large-style" on:blur={save} />
<div class="w-full mt-6">
<AttachmentStyleBoxEditor
<AttachmentStyleBoxCollabEditor
focusIndex={30}
object={template}
key={{ key: 'description', attr: descriptionKey }}
@ -163,7 +163,7 @@
/>
</div>
<div class="mt-6">
{#key template._id && currentProject !== undefined}
{#key template._id !== undefined && currentProject !== undefined}
{#if currentProject !== undefined}
<SubIssueTemplates
maxHeight="limited"
@ -204,7 +204,7 @@
</svelte:fragment>
<svelte:fragment slot="custom-attributes">
{#if template && currentProject}
{#if template !== undefined && currentProject}
<TemplateControlPanel issue={template} />
{/if}
</svelte:fragment>

View File

@ -0,0 +1,27 @@
<!--
// 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 { Doc } from '@hcengineering/core'
import { KeyedAttribute } from '@hcengineering/presentation'
import { CollaborativeAttributeSectionBox } from '@hcengineering/text-editor'
export let object: Doc
export let key: KeyedAttribute
</script>
{#key object._id}
<CollaborativeAttributeSectionBox {object} {key} label={key.attr.label} />
{/key}

View File

@ -61,7 +61,7 @@
}
onDestroy(async () => {
notificationClient.then(async (client) => {
await notificationClient.then(async (client) => {
await client.read(_id)
})
})

View File

@ -42,6 +42,7 @@ import StringFilter from './components/filter/StringFilter.svelte'
import StringFilterPresenter from './components/filter/StringFilterPresenter.svelte'
import TimestampFilter from './components/filter/TimestampFilter.svelte'
import ValueFilter from './components/filter/ValueFilter.svelte'
import CollaborativeHTMLEditor from './components/CollaborativeHTMLEditor.svelte'
import HTMLEditor from './components/HTMLEditor.svelte'
import HTMLPresenter from './components/HTMLPresenter.svelte'
import HyperlinkPresenter from './components/HyperlinkPresenter.svelte'
@ -238,6 +239,7 @@ export default async (): Promise<Resources> => ({
FilterTypePopup,
ValueSelector,
HTMLEditor,
CollaborativeHTMLEditor,
ListView,
GrowPresenter,
DividerPresenter,

View File

@ -12,8 +12,7 @@
"docker:build": "docker build -t hardcoreeng/collaborator .",
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/collaborator staging",
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/collaborator",
"run-local": "cross-env TRANSACTOR_URL=ws://localhost:3333 SECRET=secret MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ts-node src/__start.ts",
"run-bundle": "cross-env TRANSACTOR_URL=ws://localhost:3333 SECRET=secret MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin node ./bundle.js",
"run-local": "cross-env MONGO_URL=mongodb://localhost:27017 TRANSACTOR_URL=ws://localhost:3333 SECRET=secret MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ts-node src/__start.ts",
"lint": "eslint src",
"format": "format src",
"test": "jest --passWithNoTests --silent"

View File

@ -14,4 +14,4 @@
//
import { startCollaborator } from '@hcengineering/collaborator'
startCollaborator()
void startCollaborator()

View File

@ -42,7 +42,8 @@ import core, {
TxFactory,
TxProcessor,
TxRemoveDoc,
TxUpdateDoc
TxUpdateDoc,
Type
} from '@hcengineering/core'
import notification, { Collaborators, NotificationType, NotificationContent } from '@hcengineering/notification'
import { getMetadata, IntlString } from '@hcengineering/platform'
@ -54,6 +55,10 @@ import { getBacklinks, getBacklinksTxes } from './backlinks'
export { getBacklinksTxes } from './backlinks'
function isMarkupType (type: Ref<Class<Type<any>>>): boolean {
return type === core.class.TypeMarkup || type === core.class.TypeCollaborativeMarkup
}
function getCreateBacklinksTxes (
control: TriggerControl,
txFactory: TxFactory,
@ -66,7 +71,7 @@ function getCreateBacklinksTxes (
const backlinks: Data<Backlink>[] = []
const attributes = control.hierarchy.getAllAttributes(doc._class)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup) {
if (isMarkupType(attr.type._class)) {
const content = (doc as any)[attr.name]?.toString() ?? ''
const attrBacklinks = getBacklinks(backlinkId, backlinkClass, attachedDocId, content)
backlinks.push(...attrBacklinks)
@ -90,7 +95,7 @@ async function getUpdateBacklinksTxes (
const backlinks: Data<Backlink>[] = []
const attributes = control.hierarchy.getAllAttributes(doc._class)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup) {
if (isMarkupType(attr.type._class)) {
hasBacklinkAttrs = true
const content = (doc as any)[attr.name]?.toString() ?? ''
const attrBacklinks = getBacklinks(backlinkId, backlinkClass, attachedDocId, content)
@ -318,7 +323,7 @@ async function BacklinksUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]>
let hasUpdates = false
const attributes = control.hierarchy.getAllAttributes(ctx.objectClass)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup && attr.name in ctx.operations) {
if (isMarkupType(attr.type._class) && attr.name in ctx.operations) {
hasUpdates = true
break
}
@ -349,7 +354,7 @@ async function BacklinksRemove (tx: Tx, control: TriggerControl): Promise<Tx[]>
let hasMarkdown = false
const attributes = control.hierarchy.getAllAttributes(ctx.objectClass)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup) {
if (isMarkupType(attr.type._class)) {
hasMarkdown = true
break
}

View File

@ -12,8 +12,7 @@
"docker:build": "docker build -t hardcoreeng/collaborator .",
"docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/collaborator staging",
"docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/collaborator",
"run-local": "cross-env TRANSACTOR_URL=ws://localhost:3333 SECRET=secret MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ts-node src/__start.ts",
"run-bundle": "cross-env TRANSACTOR_URL=ws://localhost:3333 SECRET=secret MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin node ./bundle.js",
"run-local": "cross-env MONGO_URL=mongodb://localhost:27017 TRANSACTOR_URL=ws://localhost:3333 SECRET=secret MINIO_ENDPOINT=localhost MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin ts-node src/__start.ts",
"lint": "eslint src",
"format": "format src",
"test": "jest --passWithNoTests --silent"
@ -54,8 +53,14 @@
"@hcengineering/client": "^0.6.14",
"@hcengineering/client-resources": "^0.6.23",
"@hcengineering/minio": "^0.6.0",
"yjs": "^13.5.52",
"@hcengineering/text": "^0.6.1",
"@hocuspocus/server": "^2.5.0",
"@hocuspocus/transformer": "^2.5.0",
"@tiptap/core": "^2.1.12",
"@tiptap/html": "^2.1.12",
"mongodb": "^4.11.0",
"yjs": "^13.5.52",
"y-prosemirror": "^1.2.1",
"express": "^4.17.1",
"body-parser": "~1.19.1",
"cors": "^2.8.5",

View File

@ -15,4 +15,4 @@
//
import { startCollaborator } from './starter'
startCollaborator()
void startCollaborator()

View File

@ -25,6 +25,7 @@ export interface Config {
Port: number
TransactorUrl: string
MongoUrl: string
MinioEndpoint: string
MinioAccessKey: string
@ -37,6 +38,7 @@ const envMap: { [key in keyof Config]: string } = {
Interval: 'INTERVAL',
Port: 'COLLABORATOR_PORT',
TransactorUrl: 'TRANSACTOR_URL',
MongoUrl: 'MONGO_URL',
MinioEndpoint: 'MINIO_ENDPOINT',
MinioAccessKey: 'MINIO_ACCESS_KEY',
MinioSecretKey: 'MINIO_SECRET_KEY'
@ -47,6 +49,7 @@ const required: Array<keyof Config> = [
'ServiceID',
'Port',
'TransactorUrl',
'MongoUrl',
'MinioEndpoint',
'MinioAccessKey',
'MinioSecretKey'
@ -59,6 +62,7 @@ const config: Config = (() => {
Interval: parseInt(process.env[envMap.Interval] ?? '30000'),
Port: parseInt(process.env[envMap.Port] ?? '3078'),
TransactorUrl: process.env[envMap.TransactorUrl],
MongoUrl: process.env[envMap.MongoUrl],
MinioEndpoint: process.env[envMap.MinioEndpoint],
MinioAccessKey: process.env[envMap.MinioAccessKey],
MinioSecretKey: process.env[envMap.MinioSecretKey]

View File

@ -13,21 +13,34 @@
// limitations under the License.
//
import { generateId } from '@hcengineering/core'
import { Token, decodeToken } from '@hcengineering/server-token'
import { onAuthenticatePayload } from '@hocuspocus/server'
export interface Context {
token: Token
connectionId: string
token: string
decodedToken: Token
initialContentId: string
targetContentId: string
}
export type withContext<T> = Omit<T, 'context'> & {
context: Context
}
export function buildContext (data: onAuthenticatePayload): Context {
const token = decodeToken(data.token)
const connectionId = generateId()
const decodedToken = decodeToken(data.token)
const initialContentId = data.requestParameters.get('initialContentId') as string
const targetContentId = data.requestParameters.get('targetContentId') as string
const context: Context = {
token,
initialContentId: initialContentId ?? ''
connectionId,
decodedToken,
token: data.token,
initialContentId: initialContentId ?? '',
targetContentId: targetContentId ?? ''
}
return context

View File

@ -1,12 +1,46 @@
//
// 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.
//
import { MeasureContext } from '@hcengineering/core'
import { Connection, Document, Extension, Hocuspocus, onConfigurePayload, onStatelessPayload } from '@hocuspocus/server'
import { Transformer } from '@hocuspocus/transformer'
import * as Y from 'yjs'
import { Context } from '../context'
import { Action, ActionStatus, ActionStatusResponse, DocumentCopyAction, DocumentFieldCopyAction } from '../types'
import {
Action,
ActionStatus,
ActionStatusResponse,
DocumentContentAction,
DocumentCopyAction,
DocumentFieldCopyAction
} from '../types'
export interface ActionsConfiguration {
ctx: MeasureContext
transformer: Transformer
}
export class ActionsExtension implements Extension {
private readonly configuration: ActionsConfiguration
instance!: Hocuspocus
constructor (configuration: ActionsConfiguration) {
this.configuration = configuration
}
async onConfigure ({ instance }: onConfigurePayload): Promise<void> {
this.instance = instance
}
@ -14,21 +48,29 @@ export class ActionsExtension implements Extension {
async onStateless (data: onStatelessPayload): Promise<any> {
try {
const action = JSON.parse(data.payload) as Action
const context = data.connection.context as Context
const { connection } = data
const context = data.connection.context
const { connection, document, documentName } = data
switch (action.action) {
case 'document.copy':
await this.onCopyDocument(context, action)
this.sendActionStatus(connection, action, 'completed')
return
case 'document.field.copy':
await this.onCopyDocumentField(context, action)
this.sendActionStatus(connection, action, 'completed')
return
default:
console.error('unsupported action type', action)
}
console.log('process stateless message', action.action, documentName)
await this.configuration.ctx.with(action.action, {}, async () => {
switch (action.action) {
case 'document.content':
await this.onDocumentContent(document, action)
this.sendActionStatus(connection, action, 'completed')
return
case 'document.copy':
await this.onCopyDocument(context, action)
this.sendActionStatus(connection, action, 'completed')
return
case 'document.field.copy':
await this.onCopyDocumentField(context, action)
this.sendActionStatus(connection, action, 'completed')
return
default:
console.error('unsupported action type', action)
}
})
} catch (err: any) {
console.error('failed to process stateless message', err)
}
@ -39,16 +81,23 @@ export class ActionsExtension implements Extension {
connection.sendStateless(JSON.stringify(payload))
}
async onDocumentContent (document: Document, action: DocumentContentAction): Promise<void> {
const { content, field } = action.params
if (!document.share.has(field)) {
const ydoc = this.configuration.transformer.toYdoc(content, field)
document.merge(ydoc)
} else {
console.warn('document has already been initialized')
}
}
async onCopyDocument (context: Context, action: DocumentCopyAction): Promise<void> {
const instance = this.instance
const { sourceId, targetId } = action.params
console.info(`copy document content ${sourceId} -> ${targetId}`)
const _context: Context = {
token: context.token,
initialContentId: ''
}
const _context: Context = { ...context, initialContentId: '', targetContentId: '' }
let source: Document | null = null
let target: Document | null = null
@ -102,10 +151,7 @@ export class ActionsExtension implements Extension {
return
}
const _context: Context = {
token: context.token,
initialContentId: ''
}
const _context: Context = { ...context, initialContentId: '', targetContentId: '' }
let doc: Document | null = null

View File

@ -13,126 +13,117 @@
// limitations under the License.
//
import attachment, { Attachment } from '@hcengineering/attachment'
import client from '@hcengineering/client'
import clientResources from '@hcengineering/client-resources'
import core, { Client, MeasureContext, Ref, TxOperations } from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import { setMetadata } from '@hcengineering/platform'
import { Token, generateToken } from '@hcengineering/server-token'
import { Extension, onLoadDocumentPayload, onStoreDocumentPayload } from '@hocuspocus/server'
import { applyUpdate, encodeStateAsUpdate } from 'yjs'
import config from '../config'
import { Context } from '../context'
// eslint-disable-next-line
const WebSocket = require('ws')
async function connect (transactorUrl: string, token: Token): Promise<Client> {
const encodedToken = generateToken(token.email, token.workspace)
// We need to override default factory with 'ws' one.
setMetadata(client.metadata.ClientSocketFactory, (url) => {
return new WebSocket(url, {
headers: {
'User-Agent': config.ServiceID
}
})
})
return await (await clientResources()).function.GetClient(encodedToken, transactorUrl)
}
import { MeasureContext } from '@hcengineering/core'
import {
Document,
Extension,
afterUnloadDocumentPayload,
onChangePayload,
onDisconnectPayload,
onLoadDocumentPayload,
onStoreDocumentPayload
} from '@hocuspocus/server'
import { Doc as YDoc } from 'yjs'
import { Context, withContext } from '../context'
import { StorageAdapter } from '../storage/adapter'
export interface StorageConfiguration {
ctx: MeasureContext
minio: MinioService
transactorUrl: string
adapter: StorageAdapter
}
export class StorageExtension implements Extension {
private readonly configuration: StorageConfiguration
private readonly collaborators = new Map<string, Set<string>>()
constructor (configuration: StorageConfiguration) {
this.configuration = configuration
}
async getMinioDocument (documentId: string, token: Token): Promise<Buffer | undefined> {
const buffer = await this.configuration.minio.read(token.workspace, documentId)
return Buffer.concat(buffer)
async onChange ({ context, documentName }: withContext<onChangePayload>): Promise<any> {
const collaborators = this.collaborators.get(documentName) ?? new Set()
collaborators.add(context.connectionId)
this.collaborators.set(documentName, collaborators)
}
async onLoadDocument (data: onLoadDocumentPayload): Promise<any> {
console.log('load document', data.documentName)
async onLoadDocument ({ context, documentName }: withContext<onLoadDocumentPayload>): Promise<any> {
return await this.configuration.ctx.with('load-document', {}, async () => {
return await this.loadDocument(documentName, context)
})
}
const documentId = data.documentName
const { token, initialContentId } = data.context as Context
async onStoreDocument ({ context, documentName, document }: withContext<onStoreDocumentPayload>): Promise<void> {
const collaborators = this.collaborators.get(documentName)
if (collaborators === undefined || collaborators.size === 0) {
console.log('no changes for document', documentName)
return
}
await this.configuration.ctx.with('load-document', {}, async () => {
let minioDocument: Buffer | undefined
await this.configuration.ctx.with('store-document', {}, async () => {
this.collaborators.delete(documentName)
await this.storeDocument(documentName, document, context)
})
}
async onDisconnect ({ context, documentName, document }: withContext<onDisconnectPayload>): Promise<any> {
const { connectionId } = context
const collaborators = this.collaborators.get(documentName)
if (collaborators === undefined || !this.collaborators.has(connectionId)) {
console.log('no changes for document', documentName)
}
await this.configuration.ctx.with('store-document', {}, async () => {
this.collaborators.get(documentName)?.delete(connectionId)
await this.storeDocument(documentName, document, context)
})
}
async afterUnloadDocument ({ documentName }: afterUnloadDocumentPayload): Promise<any> {
this.collaborators.delete(documentName)
}
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const { adapter } = this.configuration
console.log('load document', documentId)
try {
const ydoc = await adapter.loadDocument(documentId, context)
if (ydoc !== undefined) {
return ydoc
}
} catch (err) {
console.error('failed to load document', documentId, err)
}
const { initialContentId } = context
if (initialContentId !== undefined && initialContentId.length > 0) {
console.log('load document initial content', initialContentId)
try {
minioDocument = await this.getMinioDocument(documentId, token)
} catch (err: any) {
if (initialContentId !== undefined && initialContentId.length > 0) {
try {
minioDocument = await this.getMinioDocument(initialContentId, token)
} catch (err: any) {
// Do nothing
// Initial content document also might not have been initialized in minio (e.g. if it's an empty template)
}
}
return await adapter.loadDocument(initialContentId, context)
} catch (err) {
console.error('failed to load document', initialContentId, err)
}
if (minioDocument !== undefined && minioDocument.length > 0) {
try {
const uint8arr = new Uint8Array(minioDocument)
applyUpdate(data.document, uint8arr)
} catch (err) {
console.error(err)
}
}
})
return data.document
}
}
async onStoreDocument (data: onStoreDocumentPayload): Promise<void> {
console.log('store document', data.documentName)
async storeDocument (documentId: string, document: Document, context: Context): Promise<void> {
const { adapter } = this.configuration
const documentId = data.documentName
const { token } = data.context as Context
console.log('store document', documentId)
try {
await adapter.saveDocument(documentId, document, context)
} catch (err) {
console.error('failed to save document', documentId, err)
}
await this.configuration.ctx.with('store-document', {}, async (ctx) => {
const updates = encodeStateAsUpdate(data.document)
const buffer = Buffer.from(updates.buffer)
// persist document to Minio
await ctx.with('minio', {}, async () => {
const metaData = { 'content-type': 'application/ydoc' }
await this.configuration.minio.put(token.workspace, documentId, buffer, buffer.length, metaData)
})
// notify platform about changes
await ctx.with('platform', {}, async () => {
try {
const connection = await connect(this.configuration.transactorUrl, token)
// token belongs to the first user opened the document, this is not accurate, but
// since the document is collaborative, we need to choose some account to update the doc
const account = await connection.findOne(core.class.Account, { email: token.email })
const accountId = account?._id ?? core.account.System
const client = new TxOperations(connection, accountId, true)
const current = await client.findOne(attachment.class.Attachment, { _id: documentId as Ref<Attachment> })
if (current !== undefined) {
console.debug('platform notification for document', documentId)
await client.update(current, { lastModified: Date.now(), size: buffer.length })
} else {
console.debug('platform attachment document not found', documentId)
}
await connection.close()
} catch (err: any) {
console.debug('failed to notify platform', documentId, err)
}
})
})
const { targetContentId } = context
if (targetContentId !== undefined && targetContentId.length > 0) {
console.log('store document target content', targetContentId)
try {
await adapter.saveDocument(targetContentId, document, context)
} catch (err) {
console.error('failed to save document', targetContentId, err)
}
}
}
}

View File

@ -0,0 +1,43 @@
//
// 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.
//
import client from '@hcengineering/client'
import clientResources from '@hcengineering/client-resources'
import core, { Client, TxOperations } from '@hcengineering/core'
import { setMetadata } from '@hcengineering/platform'
import { Token } from '@hcengineering/server-token'
import config from './config'
// eslint-disable-next-line
const WebSocket = require('ws')
export async function connect (transactorUrl: string, token: string): Promise<Client> {
// We need to override default factory with 'ws' one.
setMetadata(client.metadata.ClientSocketFactory, (url) => {
return new WebSocket(url, {
headers: {
'User-Agent': config.ServiceID
}
})
})
return await (await clientResources()).function.GetClient(token, transactorUrl)
}
export async function getTxOperations (client: Client, token: Token, isDerived: boolean = false): Promise<TxOperations> {
const account = await client.findOne(core.class.Account, { email: token.email })
const accountId = account?._id ?? core.account.System
return new TxOperations(client, accountId, isDerived)
}

View File

@ -15,25 +15,42 @@
import { MeasureContext } from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import { serverExtensions } from '@hcengineering/text'
import { Hocuspocus, onAuthenticatePayload } from '@hocuspocus/server'
import bp from 'body-parser'
import compression from 'compression'
import cors from 'cors'
import express from 'express'
import { IncomingMessage, createServer } from 'http'
import { MongoClient } from 'mongodb'
import { WebSocket, WebSocketServer } from 'ws'
import { Config } from './config'
import { ActionsExtension } from './extensions/action'
import { StorageExtension } from './extensions/storage'
import { Context, buildContext } from './context'
import { HtmlTransformer } from './transformers/html'
import { MinioStorageAdapter } from './storage/minio'
import { MongodbStorageAdapter } from './storage/mongodb'
import { PlatformStorageAdapter } from './storage/platform'
import { StorageExtension } from './extensions/storage'
import { RouterStorageAdapter } from './storage/router'
const gcEnabled = process.env.GC !== 'false' && process.env.GC !== '0'
/**
* @public
*/
export function start (ctx: MeasureContext, config: Config, minio: MinioService): () => void {
export type Shutdown = () => Promise<void>
/**
* @public
*/
export async function start (
ctx: MeasureContext,
config: Config,
minio: MinioService,
mongo: MongoClient
): Promise<Shutdown> {
const port = config.Port
console.log(`starting server on :${port} ...`)
@ -55,6 +72,9 @@ export function start (ctx: MeasureContext, config: Config, minio: MinioService)
})
)
const extensionsCtx = ctx.newChild('extensions', {})
const storageCtx = ctx.newChild('storage', {})
const hocuspocus = new Hocuspocus({
address: '0.0.0.0',
port,
@ -89,11 +109,28 @@ export function start (ctx: MeasureContext, config: Config, minio: MinioService)
unloadImmediately: false,
extensions: [
new ActionsExtension(),
new ActionsExtension({
ctx: extensionsCtx.newChild('actions', {}),
transformer: new HtmlTransformer(serverExtensions)
}),
new StorageExtension({
ctx: ctx.newChild('minio', {}),
minio,
transactorUrl: config.TransactorUrl
ctx: extensionsCtx.newChild('storage', {}),
adapter: new RouterStorageAdapter(
{
minio: new MinioStorageAdapter(storageCtx.newChild('minio', {}), minio, config.TransactorUrl),
mongodb: new MongodbStorageAdapter(
storageCtx.newChild('mongodb', {}),
mongo,
new HtmlTransformer(serverExtensions)
),
platform: new PlatformStorageAdapter(
storageCtx.newChild('platform', {}),
config.TransactorUrl,
new HtmlTransformer(serverExtensions)
)
},
'minio'
)
})
],
@ -138,7 +175,7 @@ export function start (ctx: MeasureContext, config: Config, minio: MinioService)
server.listen(port)
console.log(`started server on :${port}`)
return () => {
return async () => {
server.close()
}
}

View File

@ -17,12 +17,13 @@
import { MinioService } from '@hcengineering/minio'
import { setMetadata } from '@hcengineering/platform'
import serverToken from '@hcengineering/server-token'
import { MongoClient } from 'mongodb'
import config from './config'
import { metricsContext } from './metrics'
import { start } from './server'
export function startCollaborator (): void {
export async function startCollaborator (): Promise<void> {
setMetadata(serverToken.metadata.Secret, config.Secret)
let minioPort = 9000
@ -33,7 +34,7 @@ export function startCollaborator (): void {
minioPort = parseInt(sp[1])
}
const minio = new MinioService({
const minioClient = new MinioService({
endPoint: minioEndpoint,
port: minioPort,
useSSL: false,
@ -41,10 +42,13 @@ export function startCollaborator (): void {
secretKey: config.MinioSecretKey
})
const server = start(metricsContext, config, minio)
const mongoClient = await MongoClient.connect(config.MongoUrl)
const shutdown = await start(metricsContext, config, minioClient, mongoClient)
const close = (): void => {
server()
void mongoClient.close()
void shutdown()
}
process.on('SIGINT', close)

View File

@ -0,0 +1,25 @@
//
// 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.
//
import { Document } from '@hocuspocus/server'
import { Doc as YDoc } from 'yjs'
import { Context } from '../context'
export interface StorageAdapter {
loadDocument: (documentId: string, context: Context) => Promise<YDoc | undefined>
saveDocument: (documentId: string, document: Document, context: Context) => Promise<void>
}
export type StorageAdapters = Record<string, StorageAdapter>

View File

@ -0,0 +1,117 @@
//
// 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.
//
import attachment, { Attachment } from '@hcengineering/attachment'
import { MeasureContext, Ref } from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import { Document } from '@hocuspocus/server'
import { Doc as YDoc, applyUpdate, encodeStateAsUpdate } from 'yjs'
import { Context } from '../context'
import { StorageAdapter } from './adapter'
import { connect, getTxOperations } from '../platform'
function maybePlatformDocumentId (documentId: string): boolean {
return !documentId.includes('%')
}
export class MinioStorageAdapter implements StorageAdapter {
constructor (
private readonly ctx: MeasureContext,
private readonly minio: MinioService,
private readonly transactorUrl: string
) {}
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const {
decodedToken: { workspace }
} = context
return await this.ctx.with('load-document', {}, async (ctx) => {
const minioDocument = await ctx.with('query', {}, async () => {
try {
const buffer = await this.minio.read(workspace, documentId)
return Buffer.concat(buffer)
} catch {
return undefined
}
})
if (minioDocument === undefined) {
return undefined
}
const ydoc = new YDoc()
await ctx.with('transform', {}, () => {
try {
const uint8arr = new Uint8Array(minioDocument)
applyUpdate(ydoc, uint8arr)
} catch (err) {
console.error(err)
}
})
return ydoc
})
}
async saveDocument (documentId: string, document: Document, context: Context): Promise<void> {
const { decodedToken, token } = context
await this.ctx.with('save-document', {}, async (ctx) => {
const buffer = await ctx.with('transform', {}, () => {
const updates = encodeStateAsUpdate(document)
return Buffer.from(updates.buffer)
})
await ctx.with('update', {}, async () => {
const metadata = { 'content-type': 'application/ydoc' }
await this.minio.put(decodedToken.workspace, documentId, buffer, buffer.length, metadata)
})
// minio file is usually an attachment document
// we need to touch an attachment from here to notify platform about changes
if (!maybePlatformDocumentId(documentId)) {
// documentId is not a platform document id, we can skip platform notification
return
}
await ctx.with('platform', {}, async () => {
const connection = await ctx.with('connect', {}, async () => {
return await connect(this.transactorUrl, token)
})
try {
const client = await getTxOperations(connection, decodedToken, true)
const current = await ctx.with('query', {}, async () => {
return await client.findOne(attachment.class.Attachment, { _id: documentId as Ref<Attachment> })
})
if (current !== undefined) {
await ctx.with('update', {}, async () => {
await client.update(current, { lastModified: Date.now(), size: buffer.length })
})
}
} finally {
await connection.close()
}
})
})
}
}

View File

@ -0,0 +1,78 @@
//
// 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.
//
import { MeasureContext, toWorkspaceString } from '@hcengineering/core'
import { Document } from '@hocuspocus/server'
import { Transformer } from '@hocuspocus/transformer'
import { MongoClient } from 'mongodb'
import { Doc as YDoc } from 'yjs'
import { Context } from '../context'
import { StorageAdapter } from './adapter'
interface MongodbDocumentId {
objectDomain: string
objectId: string
objectAttr: string
}
function parseDocumentId (documentId: string): MongodbDocumentId {
const [objectDomain, objectId, objectAttr] = documentId.split('/')
return {
objectId: objectId ?? '',
objectDomain: objectDomain ?? '',
objectAttr: objectAttr ?? ''
}
}
function isValidDocumentId (documentId: MongodbDocumentId): boolean {
return documentId.objectDomain !== '' && documentId.objectId !== '' && documentId.objectAttr !== ''
}
export class MongodbStorageAdapter implements StorageAdapter {
constructor (
private readonly ctx: MeasureContext,
private readonly mongodb: MongoClient,
private readonly transformer: Transformer
) {}
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const { decodedToken } = context
const { objectId, objectDomain, objectAttr } = parseDocumentId(documentId)
if (!isValidDocumentId({ objectId, objectDomain, objectAttr })) {
console.warn('malformed document id', documentId)
return undefined
}
return await this.ctx.with('load-document', {}, async (ctx) => {
const doc = await ctx.with('query', {}, async () => {
const db = this.mongodb.db(toWorkspaceString(decodedToken.workspace))
return await db.collection(objectDomain).findOne({ _id: objectId }, { projection: { [objectAttr]: 1 } })
})
const content = doc !== null && objectAttr in doc ? (doc[objectAttr] as string) : ''
return await ctx.with('transform', {}, () => {
return this.transformer.toYdoc(content, objectAttr)
})
})
}
async saveDocument (_documentId: string, _document: Document, _context: Context): Promise<void> {
// do nothing, not supported
}
}

View File

@ -0,0 +1,120 @@
//
// 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.
//
import { Class, Doc, MeasureContext, Ref } from '@hcengineering/core'
import { Document } from '@hocuspocus/server'
import { Transformer } from '@hocuspocus/transformer'
import { Doc as YDoc } from 'yjs'
import { Context } from '../context'
import { connect, getTxOperations } from '../platform'
import { StorageAdapter } from './adapter'
interface PlatformDocumentId {
objectClass: Ref<Class<Doc>>
objectId: Ref<Doc>
objectAttr: string
}
function parseDocumentId (documentId: string): PlatformDocumentId {
const [objectClass, objectId, objectAttr] = documentId.split('/')
return {
objectClass: (objectClass ?? '') as Ref<Class<Doc>>,
objectId: (objectId ?? '') as Ref<Doc>,
objectAttr: objectAttr ?? ''
}
}
function isValidDocumentId (documentId: PlatformDocumentId): boolean {
return documentId.objectClass !== '' && documentId.objectId !== '' && documentId.objectAttr !== ''
}
export class PlatformStorageAdapter implements StorageAdapter {
constructor (
private readonly ctx: MeasureContext,
private readonly transactorUrl: string,
private readonly transformer: Transformer
) {}
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const { token } = context
const { objectId, objectClass, objectAttr } = parseDocumentId(documentId)
if (!isValidDocumentId({ objectId, objectClass, objectAttr })) {
console.warn('malformed document id', documentId)
return undefined
}
return await this.ctx.with('load-document', {}, async (ctx) => {
let content = ''
const client = await ctx.with('connect', {}, async () => {
return await connect(this.transactorUrl, token)
})
try {
const doc = await ctx.with('query', {}, async () => {
return await client.findOne(objectClass, { _id: objectId }, { projection: { [objectAttr]: 1 } })
})
if (doc !== undefined && objectAttr in doc) {
content = (doc as any)[objectAttr] as string
}
} finally {
await client.close()
}
return await ctx.with('transform', {}, () => {
return this.transformer.toYdoc(content, objectAttr)
})
})
}
async saveDocument (documentId: string, document: Document, context: Context): Promise<void> {
const { decodedToken, token } = context
const { objectId, objectClass, objectAttr } = parseDocumentId(documentId)
if (!isValidDocumentId({ objectId, objectClass, objectAttr })) {
console.warn('malformed document id', documentId)
return undefined
}
await this.ctx.with('save-document', {}, async (ctx) => {
const connection = await ctx.with('connect', {}, async () => {
return await connect(this.transactorUrl, token)
})
const client = await getTxOperations(connection, decodedToken)
try {
const current = await ctx.with('query', {}, async () => {
return await client.findOne(objectClass, { _id: objectId })
})
if (current !== undefined) {
const content = await ctx.with('transform', {}, () => {
return this.transformer.fromYdoc(document, objectAttr)
})
await ctx.with('update', {}, async () => {
if ((current as any)[objectAttr] !== content) {
await client.update(current, { [objectAttr]: content })
}
})
}
} finally {
await connection.close()
}
})
}
}

View File

@ -0,0 +1,53 @@
//
// 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.
//
import { Document } from '@hocuspocus/server'
import { Doc as YDoc } from 'yjs'
import { Context } from '../context'
import { StorageAdapter, StorageAdapters } from './adapter'
function parseDocumentName (documentId: string): { schema: string, documentName: string } {
const [schema, documentName] = documentId.split('://', 2)
return documentName !== undefined ? { documentName, schema } : { documentName: documentId, schema: '' }
}
export class RouterStorageAdapter implements StorageAdapter {
constructor (
private readonly adapters: StorageAdapters,
private readonly defaultAdapter: string
) {}
getStorageAdapter (schema: string): StorageAdapter | undefined {
return schema in this.adapters
? this.adapters[schema]
: this.defaultAdapter !== undefined
? this.adapters[this.defaultAdapter]
: undefined
}
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const { schema, documentName } = parseDocumentName(documentId)
const adapter = this.getStorageAdapter(schema)
return await adapter?.loadDocument?.(documentName, context)
}
async saveDocument (documentId: string, document: Document, context: Context): Promise<void> {
const { schema, documentName } = parseDocumentName(documentId)
const adapter = this.getStorageAdapter(schema)
await adapter?.saveDocument?.(documentName, document, context)
}
}

View File

@ -0,0 +1,41 @@
//
// 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.
//
import { TiptapTransformer, Transformer } from '@hocuspocus/transformer'
import { Extensions } from '@tiptap/core'
import { generateHTML, generateJSON } from '@tiptap/html'
import { Doc } from 'yjs'
export class HtmlTransformer implements Transformer {
transformer: Transformer
constructor (private readonly extensions: Extensions) {
this.transformer = TiptapTransformer.extensions(extensions)
}
fromYdoc (document: Doc, fieldName?: string | string[] | undefined): any {
const json = this.transformer.fromYdoc(document, fieldName)
return generateHTML(json, this.extensions)
}
toYdoc (document: any, fieldName: string): Doc {
if (typeof document === 'string' && document !== '') {
const json = generateJSON(document, this.extensions)
return this.transformer.toYdoc(json, fieldName)
}
return new Doc()
}
}

View File

@ -13,7 +13,17 @@
// limitations under the License.
//
export type Action = DocumentCopyAction | DocumentFieldCopyAction
export type Action = DocumentCopyAction | DocumentFieldCopyAction | DocumentContentAction
export type StorageType = 'minio' | 'platform'
export interface DocumentContentAction {
action: 'document.content'
params: {
field: string
content: string
}
}
export interface DocumentCopyAction {
action: 'document.copy'

View File

@ -271,7 +271,7 @@ export async function extractIndexedValues (
continue
}
if (keyAttr.type._class === core.class.TypeMarkup) {
if (keyAttr.type._class === core.class.TypeMarkup || keyAttr.type._class === core.class.TypeCollaborativeMarkup) {
sourceContent = convert(sourceContent, {
preserveNewlines: true,
selectors: [{ selector: 'img', format: 'skip' }]

View File

@ -102,6 +102,7 @@ services:
collaborator:
image: hardcoreeng/collaborator
links:
- mongodb
- minio
- transactor
ports:
@ -110,6 +111,7 @@ services:
- COLLABORATOR_PORT=3078
- SECRET=secret
- TRANSACTOR_URL=ws://localhost:3334
- MONGO_URL=mongodb://mongodb:27018
- MINIO_ENDPOINT=minio
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin