mirror of
https://github.com/hcengineering/platform.git
synced 2024-11-22 03:14:40 +03:00
UBERF-4569 Collaborative editors for Markup fields (#4247)
Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
parent
e65783bfd2
commit
2b4b97732e
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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> {
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -27,6 +27,7 @@
|
||||
"Enum": "Enum",
|
||||
"Members": "Members",
|
||||
"Hyperlink": "URL",
|
||||
"Collaborative": "Collaborative",
|
||||
"Object": "Object",
|
||||
"System": "System",
|
||||
"CreatedBy": "Created by",
|
||||
|
@ -27,6 +27,7 @@
|
||||
"Enum": "Справочник",
|
||||
"Members": "Участники",
|
||||
"Hyperlink": "URL",
|
||||
"Collaborative": "Коллаборативный",
|
||||
"Object": "Объект",
|
||||
"System": "Система",
|
||||
"CreatedBy": "Создан",
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
/>
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
}
|
||||
|
38
packages/text-editor/src/components/extension/submit.ts
Normal file
38
packages/text-editor/src/components/extension/submit.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
@ -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 }
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -63,6 +63,7 @@ export interface TextNodeAction {
|
||||
id: string
|
||||
label?: IntlString
|
||||
icon: Asset | AnySvelteComponent
|
||||
action: (params: { editor: Editor }) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"string": {
|
||||
"Activity": "Активность",
|
||||
"Added": "Добавила(а)",
|
||||
"Added": "Добавил(а)",
|
||||
"All": "Все",
|
||||
"AllActivity": "Вся активнось",
|
||||
"Attributes": "Атрибуты",
|
||||
|
@ -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
|
||||
]
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 =
|
||||
|
@ -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}
|
@ -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>
|
||||
|
@ -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>
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 }}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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}
|
@ -61,7 +61,7 @@
|
||||
}
|
||||
|
||||
onDestroy(async () => {
|
||||
notificationClient.then(async (client) => {
|
||||
await notificationClient.then(async (client) => {
|
||||
await client.read(_id)
|
||||
})
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -14,4 +14,4 @@
|
||||
//
|
||||
|
||||
import { startCollaborator } from '@hcengineering/collaborator'
|
||||
startCollaborator()
|
||||
void startCollaborator()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -15,4 +15,4 @@
|
||||
//
|
||||
|
||||
import { startCollaborator } from './starter'
|
||||
startCollaborator()
|
||||
void startCollaborator()
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
43
server/collaborator/src/platform.ts
Normal file
43
server/collaborator/src/platform.ts
Normal 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)
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
25
server/collaborator/src/storage/adapter.ts
Normal file
25
server/collaborator/src/storage/adapter.ts
Normal 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>
|
117
server/collaborator/src/storage/minio.ts
Normal file
117
server/collaborator/src/storage/minio.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
78
server/collaborator/src/storage/mongodb.ts
Normal file
78
server/collaborator/src/storage/mongodb.ts
Normal 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
|
||||
}
|
||||
}
|
120
server/collaborator/src/storage/platform.ts
Normal file
120
server/collaborator/src/storage/platform.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
53
server/collaborator/src/storage/router.ts
Normal file
53
server/collaborator/src/storage/router.ts
Normal 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)
|
||||
}
|
||||
}
|
41
server/collaborator/src/transformers/html.ts
Normal file
41
server/collaborator/src/transformers/html.ts
Normal 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()
|
||||
}
|
||||
}
|
@ -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'
|
||||
|
@ -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' }]
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user