Rework documents (#2347)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2022-11-02 14:50:14 +06:00
committed by GitHub
parent d52f7ab18b
commit f69439eaef
332 changed files with 5981 additions and 4326 deletions
@@ -16,9 +16,9 @@
-->
<script lang="ts">
import { EmployeeAccount } from '@hcengineering/contact'
import { Data, generateId, getCurrentAccount } from '@hcengineering/core'
import { Document } from '@hcengineering/document'
import { Card, getClient } from '@hcengineering/presentation'
import { Data, generateId, getCurrentAccount, Ref } from '@hcengineering/core'
import { CollaboratorDocument, Document, DocumentVersionState } from '@hcengineering/document'
import { Card, getClient, UserBoxList } from '@hcengineering/presentation'
import { Button, createFocusManager, EditBox, FocusHandler } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import document from '../plugin'
@@ -32,21 +32,62 @@
const object: Data<Document> = {
name: '',
content: 0,
editSequence: 0,
versions: 0,
attachments: 0,
labels: 0,
comments: 0,
versionCounter: 0,
responsible: [currentUser.employee]
version: 0,
latest: 0.1,
approvers: [],
authors: [currentUser.employee],
reviewers: [],
requests: 0
}
const dispatch = createEventDispatcher()
const client = getClient()
async function createDocument () {
await client.createDoc(document.class.Document, document.space.Documents, object, id)
const docId = await client.createDoc(document.class.Document, document.space.Documents, object, id)
const contentAttachmentId: Ref<CollaboratorDocument> = generateId()
const versionId = await client.addCollection(
document.class.DocumentVersion,
document.space.Documents,
docId,
document.class.Document,
'versions',
{
content: '',
description: '',
impact: '',
reason: '',
state: DocumentVersionState.Draft,
version: 0.1,
attachments: 0,
comments: 0,
contentAttachmentId,
initialContentId: '' as Ref<CollaboratorDocument>
}
)
await client.addCollection(
document.class.CollaboratorDocument,
document.space.Documents,
versionId,
document.class.DocumentVersion,
'attachments',
{
file: contentAttachmentId,
name: 'content',
size: 0,
type: 'application/ydoc',
description: '',
pinned: false,
lastModified: Date.now()
},
contentAttachmentId
)
dispatch('close', id)
}
@@ -75,4 +116,39 @@
focusIndex={1}
/>
</div>
<svelte:fragment slot="pool">
<div class="flex flex-wrap" style:gap={'0.2vw'}>
<UserBoxList
items={object.authors}
size="small"
label={document.string.Authors}
emptyLabel={document.string.Authors}
kind="no-border"
width={'min-content'}
on:update={({ detail }) => (object.authors = detail)}
/>
</div>
<div class="flex flex-wrap" style:gap={'0.2vw'}>
<UserBoxList
items={object.approvers}
size="small"
label={document.string.Approvers}
emptyLabel={document.string.Approvers}
kind="no-border"
width={'min-content'}
on:update={({ detail }) => (object.approvers = detail)}
/>
</div>
<div class="flex flex-wrap" style:gap={'0.2vw'}>
<UserBoxList
items={object.reviewers}
size="small"
label={document.string.Reviewers}
emptyLabel={document.string.Reviewers}
kind="no-border"
width={'min-content'}
on:update={({ detail }) => (object.reviewers = detail)}
/>
</div>
</svelte:fragment>
</Card>
@@ -1,204 +0,0 @@
<!--
//
// Copyright © 2022 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 { AttachedData, generateId, Ref } from '@hcengineering/core'
import { Document, DocumentVersion, RichDocumentContent } from '@hcengineering/document'
import { Card, getClient } from '@hcengineering/presentation'
import { createFocusManager, FocusHandler, Icon, Label } from '@hcengineering/ui'
import Scroller from '@hcengineering/ui/src/components/Scroller.svelte'
import { Editor } from '@tiptap/core'
import Highlight from '@tiptap/extension-highlight'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import StarterKit from '@tiptap/starter-kit'
import { Transaction } from 'prosemirror-state'
import { Step } from 'prosemirror-transform'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import document from '../plugin'
import { UniqId } from '../uniqId'
// export let objectId: Ref<Doc>
// export let space: Ref<Space>
// export let _class: Ref<Class<Doc>>
export let object: Document
// export let content: string
export function canClose (): boolean {
return true
}
const id: Ref<DocumentVersion> = generateId()
const versionObject: AttachedData<DocumentVersion> = {
version: 0,
sequenceNumber: object.editSequence,
content: '',
approved: null
}
const dispatch = createEventDispatcher()
const client = getClient()
async function createDocumentVersion () {
versionObject.content = editor.getHTML()
const incResult = await client.update(
object,
{
$inc: { versionCounter: 1 }
},
true
)
versionObject.version = ((incResult as any).object as Document).versionCounter
await client.addCollection(
document.class.DocumentVersion,
document.space.Documents,
object._id,
object._class,
'versions',
versionObject,
id
)
dispatch('close', id)
}
let lastVersion: DocumentVersion | undefined
let richContent: RichDocumentContent[] = []
function recreateDocument (version?: DocumentVersion): void {
const maxVersion = richContent.reduce((a, b) => Math.max(a, b.version), 0)
const toApply = richContent.findIndex((it) => it.version === (version?.sequenceNumber ?? maxVersion))
// Filter steps if some next ones contain them in ignored.
const asteps: Step[] = []
// const newSteps: Step[] = []
for (const a of richContent.slice(0, toApply)) {
asteps.push(...a.steps.map((it) => Step.fromJSON(editor.schema, it)))
}
for (const a of richContent.slice(toApply)) {
asteps.push(...a.steps.map((it) => Step.fromJSON(editor.schema, it)))
}
const tr = editor.state.tr
for (let i = 0; i < asteps.length; i++) {
try {
tr.step(asteps[i])
} catch (err: any) {
// debugger
console.trace(err)
}
}
if (tr.docChanged) {
tr.setMeta('addToHistory', false)
const newState = editor.state.apply(tr)
editor.view.updateState(newState)
}
}
async function update (): Promise<void> {
lastVersion = await client.findOne(document.class.DocumentVersion, {}, { sort: { version: -1 } })
richContent = await client.findAll(document.class.RichDocumentContent, { attachedTo: object._id })
recreateDocument(lastVersion)
}
update()
let editor: Editor
onMount(() => {
editor = new Editor({
element,
content: '',
editable: false,
extensions: [
StarterKit,
Highlight,
Link.configure({ openOnClick: false }),
// ...(supportSubmit ? [Handle] : []), // order important
// Typography, // we need to disable 1/2 -> ½ rule (https://github.com/hcengineering/anticrm/issues/345)
Placeholder.configure({ placeholder: '' }),
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: 'flex flex-grow gap-1 checkbox_style'
}
}),
UniqId
// ...extensions
],
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
},
onBlur: ({ event }) => {},
onFocus: () => {},
onUpdate: (op: { editor: Editor; transaction: Transaction }) => {},
onSelectionUpdate: () => {}
})
})
onDestroy(() => {
if (editor) {
editor.destroy()
}
})
const manager = createFocusManager()
let element: HTMLElement
</script>
<FocusHandler {manager} />
<Card
label={document.string.CreateDocumentVersion}
okAction={createDocumentVersion}
canSave={true}
on:close={() => {
dispatch('close')
}}
>
<div class="flex-row clear-mins">
<div class="mr-3 flex-col">
<div class="flex-row-center">
<div class="p-1">
<Icon icon={document.icon.Document} size={'medium'} />
</div>
{#if lastVersion === undefined}
<Label label={document.string.NoVersions} />
{/if}
</div>
<div class="flex-row-center">
<Label label={document.string.Revision} />
{object.editSequence}
</div>
</div>
<div class="mt-4 flex-row flex-grow">
<div class="h-75p background-bg-focused border-radius-1 p-2">
<Scroller>
<div class="select-text" style="width: 100%;" bind:this={element} />
</Scroller>
</div>
</div>
</div>
</Card>
@@ -15,345 +15,27 @@
//
-->
<script lang="ts">
import { generateId, Ref } from '@hcengineering/core'
import { Document, RichDocumentContent } from '@hcengineering/document'
import { IntlString, translate } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { DocumentVersion } from '@hcengineering/document'
import { getMetadata } from '@hcengineering/platform'
import { Editor, HTMLContent } from '@tiptap/core'
import Highlight from '@tiptap/extension-highlight'
import Link from '@tiptap/extension-link'
// import Typography from '@tiptap/extension-typography'
import Placeholder from '@tiptap/extension-placeholder'
// import Collab from '@tiptap/extension-collaboration'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import StarterKit from '@tiptap/starter-kit'
import { Transaction } from 'prosemirror-state'
import { Step, Transform } from 'prosemirror-transform'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import login from '@hcengineering/login'
import document from '../plugin'
import { UniqId } from '../uniqId'
export let object: Document
export let defaultTimeout = 1500
import { CollaboratorEditor } from '@hcengineering/text-editor'
const client = getClient()
export let object: DocumentVersion
export let readonly = false
export let initialContentId: string | undefined = undefined
interface Rebaseable {
step: Step
inverted: Step
origin: Transform
}
const contentQuery = createQuery()
let richContent: RichDocumentContent[] = []
let appliedSequence: number = 0
let applyTimer: number | undefined = undefined
// Uncommited steps.
let steps: Rebaseable[] = []
$: contentQuery.query(
document.class.RichDocumentContent,
{ attachedTo: object._id, collection: 'content' },
(res) => {
richContent = res
},
{ sort: { version: 1 } }
)
let content: string = ''
export let placeholder: IntlString = document.string.EditorPlaceholder
let element: HTMLElement
let editor: Editor
let placeHolderStr: string = ''
const ownRichContent: Ref<RichDocumentContent>[] = [generateId()]
$: ph = translate(placeholder, {}).then((r) => {
placeHolderStr = r
})
const dispatch = createEventDispatcher()
export function submit (): void {
content = editor.getHTML()
dispatch('content', content)
}
export function clear (): void {
content = ''
editor.commands.clearContent(false)
}
export function insertText (text: string): void {
editor.commands.insertContent(text as HTMLContent)
}
export function toggleBold () {
editor.commands.toggleBold()
}
export function toggleItalic () {
editor.commands.toggleItalic()
}
export function toggleStrike () {
editor.commands.toggleStrike()
}
export function getLink () {
return editor.getAttributes('link').href
}
export function unsetLink () {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}
export function setLink (link: string) {
editor.chain().focus().extendMarkRange('link').setLink({ href: link }).run()
}
export function checkIsSelectionEmpty () {
return editor.view.state.selection.empty
}
export function toggleOrderedList () {
editor.commands.toggleOrderedList()
}
export function toggleBulletList () {
editor.commands.toggleBulletList()
}
export function toggleBlockquote () {
editor.commands.toggleBlockquote()
}
export function toggleCode () {
editor.commands.toggleCode()
}
export function toggleCodeBlock () {
editor.commands.toggleCodeBlock()
}
let needFocus = false
let focused = false
export function focus (): void {
needFocus = true
}
$: if (editor && needFocus) {
if (!focused) {
editor.commands.focus()
}
needFocus = false
}
function unconfirmedFrom (transform: Transform): Rebaseable[] {
const result = []
for (let i = 0; i < transform.steps.length; i++) {
result.push({
step: transform.steps[i],
inverted: transform.steps[i].invert(transform.docs[i]),
origin: transform
})
}
return result
}
function rebaseSteps (steps: readonly Rebaseable[], over: readonly Step[], transform: Transform): Rebaseable[] {
for (let i = steps.length - 1; i >= 0; i--) transform.step(steps[i].inverted)
for (let i = 0; i < over.length; i++) transform.step(over[i])
const result = []
for (let i = 0, mapFrom = steps.length; i < steps.length; i++) {
const mapped = steps[i].step.map(transform.mapping.slice(mapFrom))
mapFrom--
if (mapped && !transform.maybeStep(mapped).failed) {
;(transform.mapping as any).setMirror(mapFrom, transform.steps.length - 1)
result.push({
step: mapped,
inverted: mapped.invert(transform.docs[transform.docs.length - 1]),
origin: steps[i].origin
})
}
}
return result
}
function applyEditorSteps (editor: Editor, currentSequence: number, richContent: RichDocumentContent[]): void {
const maxVersion = richContent.reduce((a, b) => Math.max(a, b.version), 0)
const toApply = richContent.filter((it) => it.version > currentSequence && !ownRichContent.includes(it._id))
// Filter steps if some next ones contain them in ignored.
const asteps: Step[] = []
for (const a of toApply) {
asteps.push(...a.steps.map((it) => Step.fromJSON(editor.schema, it)))
}
const tr = editor.state.tr
if (steps.length > 0) {
steps = rebaseSteps(steps, asteps, tr)
} else {
for (let i = 0; i < asteps.length; i++) {
try {
tr.step(asteps[i])
} catch (err: any) {
// debugger
console.trace(err)
}
}
}
if (tr.docChanged) {
tr.setMeta('addToHistory', false)
const newState = editor.state.apply(tr)
editor.view.updateState(newState)
appliedSequence = maxVersion
content = editor.getHTML()
}
}
$: if (editor !== undefined) {
// Apply all transactions to editor
applyEditorSteps(editor, appliedSequence, richContent)
}
export async function applySteps (): Promise<void> {
const seq = object.editSequence
if (seq === appliedSequence) {
const ops = client.apply(object._class + object._id).match<Document>(document.class.Document, {
editSequence: object.editSequence
})
await ops.update(object, { editSequence: seq + 1 })
const sendSteps = steps.length
if (sendSteps === 0) {
return
}
const toSend: Step[] = []
let c: Step | null = null
const rawSteps = steps.slice(0, sendSteps).map((it) => it.step)
for (const s of rawSteps) {
if (toSend.length === 0) {
toSend.push(s)
} else if ((c = toSend[toSend.length - 1].merge(s)) != null) {
toSend[toSend.length - 1] = c
} else {
toSend.push(s)
}
}
await ops.addCollection(
document.class.RichDocumentContent,
object.space,
object._id,
object._class,
'content',
{
version: seq + 1,
steps: toSend.map((it) => it.toJSON())
},
ownRichContent[ownRichContent.length - 1]
)
if (await ops.commit()) {
steps.splice(0, sendSteps)
ownRichContent.push(generateId())
return
}
}
// retry in case of failure
clearTimeout(applyTimer)
applyTimer = setTimeout(applySteps, 100 + Math.random() * 500)
}
onMount(() => {
ph.then(() => {
editor = new Editor({
element,
content: content,
extensions: [
StarterKit,
Highlight,
Link.configure({ openOnClick: false }),
// ...(supportSubmit ? [Handle] : []), // order important
// Typography, // we need to disable 1/2 -> ½ rule (https://github.com/hcengineering/anticrm/issues/345)
Placeholder.configure({ placeholder: placeHolderStr }),
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: 'flex flex-grow gap-1 checkbox_style'
}
}),
UniqId
// ...extensions
],
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
},
onBlur: ({ event }) => {
focused = false
dispatch('blur', event)
applySteps()
},
onFocus: () => {
focused = true
dispatch('focus', editor.getHTML())
},
onUpdate: (op: { editor: Editor; transaction: Transaction }) => {
if (op.transaction.docChanged) {
steps = steps.concat(unconfirmedFrom(op.transaction))
clearTimeout(applyTimer)
applyTimer = setTimeout(applySteps, defaultTimeout)
}
content = editor.getHTML()
},
onSelectionUpdate: () => dispatch('selection-update')
})
})
})
onDestroy(() => {
applySteps()
if (editor) {
editor.destroy()
}
})
const token = getMetadata(login.metadata.LoginToken) ?? ''
const collaboratorURL = getMetadata(document.metadata.CollaboratorUrl) ?? ''
</script>
<div class="select-text" style="width: 100%;" bind:this={element} />
<style lang="scss" global>
.ProseMirror {
flex-grow: 1;
overflow-y: auto;
max-height: 60vh;
outline: none;
line-height: 150%;
color: var(--accent-color);
p:not(:last-child) {
margin-block-end: 1em;
}
> * + * {
margin-top: 0.75em;
}
/* Placeholder (at the top) */
p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--dark-color);
pointer-events: none;
height: 0;
}
&::-webkit-scrollbar-thumb {
background-color: var(--theme-bg-accent-hover);
}
&::-webkit-scrollbar-corner {
background-color: var(--theme-bg-accent-hover);
}
&::-webkit-scrollbar-track {
margin: 0;
}
}
</style>
<CollaboratorEditor
documentId={object.contentAttachmentId}
{token}
{collaboratorURL}
{readonly}
on:content
{initialContentId}
/>
@@ -33,6 +33,6 @@
<div class="icon">
<Icon icon={document.icon.Document} size={'small'} />
</div>
<span class="label">{value.name}</span>
<span class="label">{value.name}-{value.version}</span>
</a>
{/if}
@@ -15,12 +15,24 @@
//
-->
<script lang="ts">
import { DocumentVersion } from '@hcengineering/document'
import { getPanelURI, Icon, Label } from '@hcengineering/ui'
import { Ref, WithLookup } from '@hcengineering/core'
import { Document, DocumentVersion } from '@hcengineering/document'
import { getClient } from '@hcengineering/presentation'
import { getPanelURI, Icon } from '@hcengineering/ui'
import document from '../plugin'
export let value: DocumentVersion
export let value: WithLookup<DocumentVersion>
export let inline = false
$: doc = value.$lookup?.attachedTo as Document
$: if (doc === undefined) {
getClient()
.findOne(document.class.Document, { _id: value.attachedTo as Ref<Document> })
.then((res) => {
doc = res as Document
})
}
</script>
{#if value}
@@ -33,8 +45,7 @@
<Icon icon={document.icon.Document} size={'small'} />
</div>
<span class="label">
<Label label={document.string.Version} />
{value.version}
{doc?.name} - {value.version}
</span>
</a>
{/if}
@@ -1,60 +0,0 @@
<!--
//
// Copyright © 2022 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 { Document } from '@hcengineering/document'
import { Button, Icon, IconAdd, Label, showPopup } from '@hcengineering/ui'
import { BuildModelKey } from '@hcengineering/view'
import { Table } from '@hcengineering/view-resources'
import document from '../plugin'
import CreateDocumentVersion from './CreateDocumentVersion.svelte'
export let object: Document
const createApp = (ev: MouseEvent): void => {
showPopup(CreateDocumentVersion, { object }, 'top')
}
const config: (BuildModelKey | string)[] = ['version', 'sequenceNumber', 'approved']
</script>
<div class="antiSection">
<div class="antiSection-header">
<div class="antiSection-header__icon">
<Icon icon={document.icon.Document} size={'small'} />
</div>
<span class="antiSection-header__title">
<Label label={document.string.Versions} />
</span>
<Button id="appls.add" icon={IconAdd} kind={'transparent'} shape={'circle'} on:click={createApp} />
</div>
{#if object.versions > 0}
<Table
_class={document.class.DocumentVersion}
{config}
query={{ attachedTo: object._id }}
loadingProps={{ length: object.versions }}
/>
{:else}
<div class="antiSection-empty solid flex-col-center mt-3">
<span class="dark-color">
<Label label={document.string.NoVersions} />
</span>
<span class="over-underline content-accent-color" on:click={createApp}>
<Label label={document.string.CreateAnVersion} />
</span>
</div>
{/if}
</div>
@@ -1,159 +0,0 @@
<!--
//
// Copyright © 2022 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 { Document, RichDocumentContent } from '@hcengineering/document'
import { getClient } from '@hcengineering/presentation'
import { Editor } from '@tiptap/core'
import Highlight from '@tiptap/extension-highlight'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import StarterKit from '@tiptap/starter-kit'
import { Transaction } from 'prosemirror-state'
import { Step } from 'prosemirror-transform'
import { onDestroy, onMount } from 'svelte'
import document from '../plugin'
import { UniqId } from '../uniqId'
export let object: Document
export let revision: number
export function canClose (): boolean {
return true
}
const client = getClient()
let richContent: RichDocumentContent[] = []
function recreateDocument (version: number): void {
const toApply = richContent.filter((it) => it.version <= version)
// Filter steps if some next ones contain them in ignored.
const asteps: Step[] = []
for (const a of toApply) {
asteps.push(...a.steps.map((it) => Step.fromJSON(editor.schema, it)))
}
const tr = editor.state.tr.delete(0, editor.state.doc.content.size)
for (let i = 0; i < asteps.length; i++) {
try {
tr.step(asteps[i])
} catch (err: any) {
// debugger
console.trace(err)
}
}
if (tr.docChanged) {
tr.setMeta('addToHistory', false)
const newState = editor.state.apply(tr)
editor.view.updateState(newState)
}
}
async function update (revision: number): Promise<void> {
richContent = await client.findAll(document.class.RichDocumentContent, { attachedTo: object._id })
recreateDocument(revision)
}
$: update(revision)
let editor: Editor
onMount(() => {
editor = new Editor({
element,
content: '',
editable: false,
extensions: [
StarterKit,
Highlight,
Link.configure({ openOnClick: false }),
// ...(supportSubmit ? [Handle] : []), // order important
// Typography, // we need to disable 1/2 -> ½ rule (https://github.com/hcengineering/anticrm/issues/345)
Placeholder.configure({ placeholder: '' }),
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: 'flex flex-grow gap-1 checkbox_style'
}
}),
UniqId
// ...extensions
],
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
},
onBlur: ({ event }) => {},
onFocus: () => {},
onUpdate: (op: { editor: Editor; transaction: Transaction }) => {},
onSelectionUpdate: () => {}
})
})
onDestroy(() => {
if (editor) {
editor.destroy()
}
})
let element: HTMLElement
</script>
<div class="select-text" style="width: 100%;" bind:this={element} />
<style lang="scss" global>
.ProseMirror {
overflow-y: auto;
max-height: 60vh;
outline: none;
line-height: 150%;
color: var(--accent-color);
p:not(:last-child) {
margin-block-end: 1em;
}
> * + * {
margin-top: 0.75em;
}
/* Placeholder (at the top) */
p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--dark-color);
pointer-events: none;
height: 0;
}
&::-webkit-scrollbar-thumb {
background-color: var(--theme-bg-accent-hover);
}
&::-webkit-scrollbar-corner {
background-color: var(--theme-bg-accent-hover);
}
&::-webkit-scrollbar-track {
margin: 0;
}
}
</style>
@@ -16,12 +16,20 @@
-->
<script lang="ts">
import { Attachments } from '@hcengineering/attachment-resources'
import core, { Class, Doc, Ref, WithLookup } from '@hcengineering/core'
import { Document, DocumentVersion } from '@hcengineering/document'
import { EmployeeAccount } from '@hcengineering/contact'
import core, { Class, Doc, generateId, getCurrentAccount, Ref, WithLookup } from '@hcengineering/core'
import {
CollaboratorDocument,
Document,
DocumentRequest,
DocumentRequestKind,
DocumentVersion,
DocumentVersionState
} from '@hcengineering/document'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
import { getResource } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { getResource, translate } from '@hcengineering/platform'
import { createQuery, getClient, MessageViewer } from '@hcengineering/presentation'
import tags from '@hcengineering/tags'
import {
@@ -29,9 +37,11 @@
Component,
EditBox,
eventToHTMLElement,
IconAdd,
IconCheck,
IconClose,
IconEdit,
IconMoreH,
IconShare,
Label,
SelectPopup,
showPopup
@@ -40,9 +50,7 @@
import ClassAttributeBar from '@hcengineering/view-resources/src/components/ClassAttributeBar.svelte'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import document from '../plugin'
import CreateDocumentVersion from './CreateDocumentVersion.svelte'
import DocumentEditor from './DocumentEditor.svelte'
import DocumentViewer from './DocumentViewer.svelte'
// import ControlPanel from './ControlPanel.svelte'
// import CopyToClipboard from './CopyToClipboard.svelte'
@@ -63,7 +71,6 @@
let name = ''
let innerWidth: number
let isEditing = false
const notificationClient = getResource(notification.function.GetNotificationClient).then((res) => res())
@@ -78,10 +85,21 @@
}
}
const currentUser = getCurrentAccount() as EmployeeAccount
onDestroy(async () => {
notificationClient.then((client) => client.updateLastView(_id, _class))
})
let requests: DocumentRequest[] = []
$: myRequests = requests.filter((it) => it.assignee === currentUser.employee)
$: approveRequest = myRequests.find((it) => it.kind === DocumentRequestKind.Approve)
$: allApproveRequest = requests.find((it) => it.kind === DocumentRequestKind.Approve)
// $: changesRequest = myRequests.find((it) => it.kind === DocumentRequestKind.Changes)
// $: reviewRequest = myRequests.find((it) => it.kind === DocumentRequestKind.Review)
$: _id &&
_class &&
query.query(
@@ -89,31 +107,22 @@
{ _id },
async (result) => {
;[documentObject] = result
name = documentObject.name
name = documentObject?.name ?? ''
requests = (documentObject.$lookup?.requests as DocumentRequest[]) ?? []
},
{}
{
lookup: {
_id: {
requests: document.class.DocumentRequest
}
}
}
)
$: canSave = name.trim().length > 0
function edit (ev: MouseEvent) {
ev.preventDefault()
isEditing = true
}
let documentEditor: DocumentEditor
function commitEditing (ev: MouseEvent) {
ev.preventDefault()
documentEditor.applySteps().then(() => {
autoSelect = true
isEditing = false
})
}
async function save (ev: Event) {
async function saveTitle (ev: Event) {
ev.preventDefault()
if (!documentObject || !canSave) {
@@ -134,7 +143,7 @@
}
onMount(() => {
dispatch('open', { ignoreKeys: ['comments', 'name', 'description', 'number'] })
dispatch('open', { ignoreKeys: ['comments', 'name'] })
})
const versionQuery = createQuery()
@@ -145,6 +154,9 @@
{ attachedTo: _id },
(res) => {
versions = res
if (autoSelect) {
version = versions[versions.length - 1]
}
},
{ sort: { version: 1 } }
)
@@ -152,19 +164,28 @@
let info: any
let labels: Record<DocumentVersionState, string> = {
[DocumentVersionState.Draft]: '',
[DocumentVersionState.Approved]: '',
[DocumentVersionState.Rejected]: ''
}
async function updateLabels (): Promise<void> {
labels = {
[DocumentVersionState.Draft]: await translate(document.string.Draft, {}),
[DocumentVersionState.Approved]: await translate(document.string.Approved, {}),
[DocumentVersionState.Rejected]: await translate(document.string.Rejected, {})
}
}
updateLabels()
$: {
const ifo: any = [
...versions.map((it) => ({
id: it._id as string,
text: `${it.version} - ${new Date(it.modifiedOn).toDateString()} `
text: `${it.version} - ${labels[it.state]} - ${new Date(it.modifiedOn).toDateString()} `
}))
]
if (lastVersion === undefined || lastVersion.sequenceNumber !== documentObject?.editSequence) {
ifo.push({
id: '#latest',
label: document.string.LastRevision
})
}
info = ifo
}
function selectVersion (event: MouseEvent): void {
@@ -184,16 +205,177 @@
)
}
function createVersion (event: MouseEvent) {
showPopup(CreateDocumentVersion, { object: documentObject }, 'top')
}
$: lastVersion = versions[versions.length - 1]
$: readonly = !documentObject?.authors.includes(currentUser.employee)
let autoSelect = true
$: if (autoSelect && version === undefined && lastVersion) {
version = lastVersion
autoSelect = false
type ModelType = 'view' | 'edit' // | 'suggest'
let mode: ModelType = 'view'
const modeLabels = {
view: document.string.ViewMode,
edit: document.string.EditMode
// ,suggest: document.string.SuggestMode
}
function selectMode (event: MouseEvent): void {
showPopup(
SelectPopup,
{
value: Object.entries(modeLabels).map(([mode, label]) => ({
id: mode,
label
})),
placeholder: document.string.Version,
searchable: false
},
eventToHTMLElement(event),
(res) => {
if (res != null) {
mode = res
}
}
)
}
async function doEdit (documentObject: Document): Promise<void> {
processing = true
// Looking for a draft version
const draft = versions.find((it) => it.state === DocumentVersionState.Draft)
const lastVersion = versions[versions.length - 1]
if (draft === undefined) {
// We need to create draft document.
const newVersion = Math.round(documentObject.latest * 1000 + 100) / 1000
const versionId: Ref<DocumentVersion> = generateId()
const contentAttachmentId: Ref<CollaboratorDocument> = generateId()
const ops = client
.apply(documentObject._id)
.match(document.class.Document, { _id: documentObject._id, latest: documentObject.latest })
ops.update(documentObject, { latest: newVersion })
ops.addCollection(
document.class.DocumentVersion,
documentObject.space,
documentObject._id,
documentObject._class,
'versions',
{
content: lastVersion.content,
description: '',
impact: '',
reason: '',
state: DocumentVersionState.Draft,
version: newVersion,
attachments: 0,
comments: 0,
initialContentId: lastVersion.contentAttachmentId,
contentAttachmentId
},
versionId
)
ops.addCollection(
document.class.CollaboratorDocument,
documentObject.space,
versionId,
document.class.DocumentVersion,
'attachments',
{
file: contentAttachmentId,
name: 'content',
size: 0,
type: 'application/ydoc',
description: '',
pinned: false,
lastModified: Date.now()
},
contentAttachmentId
)
if (await ops.commit()) {
// We should have a draft created by someone else.
version = undefined
autoSelect = true
}
}
mode = 'edit'
processing = false
}
let processing = false
let content = ''
const updateRequests = async (kind: DocumentRequestKind): Promise<void> => {
processing = true
if (documentObject === undefined) {
return
}
const requests = await client.findAll(document.class.DocumentRequest, {
attachedTo: documentObject?._id,
kind
})
for (const a of documentObject?.approvers ?? []) {
const ex = requests.find((it) => it.assignee === a)
if (ex === undefined) {
await client.addCollection(
document.class.DocumentRequest,
documentObject.space,
documentObject._id,
documentObject._class,
'requests',
{
assignee: a,
kind
}
)
}
}
if (version) {
await client.update(version, {
content
})
}
mode = 'view'
processing = false
}
const updateState = async (state: DocumentVersionState): Promise<void> => {
processing = true
if (documentObject === undefined) {
return
}
const draft = versions.find((it) => it.state === DocumentVersionState.Draft)
if (draft !== undefined) {
// We need to create draft document.
const newVersion = documentObject.version + 1
const ops = client
.apply(documentObject._id)
.match(document.class.Document, { _id: documentObject._id, latest: documentObject.latest })
if (state === DocumentVersionState.Approved) {
ops.update(documentObject, { latest: newVersion, version: newVersion })
ops.update(draft, { version: newVersion, state })
} else {
ops.update(draft, { state })
}
// Remove all requests
if (await ops.commit()) {
const docs = await client.findAll(document.class.DocumentRequest, { attachedTo: documentObject._id })
for (const d of docs) {
client.remove(d)
}
}
}
processing = false
}
</script>
@@ -203,7 +385,6 @@
isHeader
isAside={true}
isSub={false}
withoutActivity={isEditing}
bind:innerWidth
on:close={() => dispatch('close')}
>
@@ -216,59 +397,105 @@
bind:value={name}
placeholder={document.string.DocumentNamePlaceholder}
kind="large-style"
on:blur={(evt) => save(evt)}
on:blur={(evt) => saveTitle(evt)}
/>
<div class="p-1">-</div>
<Button disabled={isEditing || info.length === 1} kind={'link-bordered'} on:click={selectVersion}>
<Button loading={processing} kind={'link-bordered'} on:click={selectVersion} disabled={info.length < 2}>
<svelte:fragment slot="content">
{#if version && !isEditing}
<Label label={document.string.Version} /> {version.version}
{#if version}
{version.version} - {labels[version.state]}
{:else}
<Label label={document.string.Revision} /> {documentObject.editSequence}
<Label label={document.string.Draft} />
{/if}
</svelte:fragment>
</Button>
{#if !isEditing}
{#if documentObject && version && version.sequenceNumber !== documentObject.editSequence}
(<Label label={document.string.Revision} /> {version.sequenceNumber} / {documentObject.editSequence})
{:else if documentObject}
(<Label label={document.string.Revision} /> {documentObject.editSequence})
{/if}
{/if}
</span>
</svelte:fragment>
<svelte:fragment slot="tools">
{#if isEditing}
<Button disabled={!canSave} label={presentation.string.Save} on:click={commitEditing} />
{:else}
<Button icon={IconEdit} kind={'transparent'} size={'medium'} on:click={edit} />
{/if}
{#if lastVersion === undefined || lastVersion.sequenceNumber !== documentObject.editSequence}
{#if version && version?.state !== DocumentVersionState.Draft}
<Button
icon={IconAdd}
kind={'transparent'}
loading={processing}
kind={'link-bordered'}
label={document.string.CreateDraft}
on:click={() => {
if (documentObject) {
doEdit(documentObject)
}
}}
icon={IconEdit}
size={'medium'}
on:click={createVersion}
showTooltip={{ label: document.string.CreateDocumentVersion }}
/>
{/if}
{#if !readonly && version && version?.state === DocumentVersionState.Draft && version.version === documentObject.latest && !allApproveRequest}
<Button
loading={processing}
kind={'link-bordered'}
label={document.string.SendForApproval}
on:click={() => updateRequests(DocumentRequestKind.Approve)}
icon={IconShare}
size={'medium'}
disabled={documentObject?.approvers?.length === 0}
/>
<Button
loading={processing}
kind={'link-bordered'}
label={document.string.SendForReview}
on:click={() => updateRequests(DocumentRequestKind.Review)}
icon={IconShare}
size={'medium'}
disabled={documentObject?.reviewers?.length === 0}
/>
{/if}
{#if version?.state === DocumentVersionState.Draft && approveRequest}
<Button
loading={processing}
kind={'link-bordered'}
label={document.string.Approve}
on:click={() => updateState(DocumentVersionState.Approved)}
icon={IconCheck}
size={'medium'}
/>
<Button
loading={processing}
kind={'link-bordered'}
label={document.string.Reject}
on:click={() => updateState(DocumentVersionState.Rejected)}
icon={IconClose}
size={'medium'}
/>
{/if}
{#if !readonly && version?.state === DocumentVersionState.Draft && approveRequest === undefined}
<Button loading={processing} kind={'link-bordered'} on:click={selectMode} icon={IconEdit} size={'medium'}>
<svelte:fragment slot="content">
<Label label={modeLabels[mode]} />
</svelte:fragment>
</Button>
{/if}
<Button icon={IconMoreH} kind={'transparent'} size={'medium'} on:click={showMenu} />
</svelte:fragment>
{#if isEditing}
<div class="popupPanel-body__main-content py-10 clear-mins content">
<DocumentEditor object={documentObject} bind:this={documentEditor} />
</div>
{:else}
<div class="description-preview select-text mt-2">
<DocumentViewer object={documentObject} revision={version?.sequenceNumber ?? documentObject.editSequence} />
</div>
<div class="description-preview select-text mt-2 emphasized">
{#if version && version.state === DocumentVersionState.Draft && approveRequest === undefined}
{#key version?._id}
<DocumentEditor
object={version}
initialContentId={version.initialContentId}
readonly={mode === 'view'}
on:content={(evt) => {
content = evt.detail
}}
/>
{/key}
{:else if version}
<MessageViewer message={version.content} />
{/if}
</div>
<div class="p-1 mt-6">
<Attachments objectId={documentObject._id} space={documentObject.space} _class={documentObject._class} />
</div>
{/if}
<div class="p-1 mt-6">
<Attachments objectId={documentObject._id} space={documentObject.space} _class={documentObject._class} />
</div>
<svelte:fragment slot="custom-attributes">
<ClassAttributeBar
@@ -276,6 +503,7 @@
_class={documentObject._class}
to={core.class.Doc}
ignoreKeys={['name']}
{readonly}
/>
<div class="divider" />
@@ -295,12 +523,7 @@
<span class="label">
<Label label={document.string.LastRevision} />
</span>
<span>{documentObject?.editSequence}</span>
<span class="label">
<Label label={document.string.Versions} />
</span>
<span>{documentObject?.versions}</span>
<span>{documentObject?.latest}</span>
</div>
</svelte:fragment>
</Panel>
@@ -332,4 +555,10 @@
.labelTop {
align-self: start;
}
.emphasized {
padding: 1rem;
background-color: var(--theme-bg-accent-color);
border: 1px solid var(--theme-bg-accent-hover);
border-radius: 0.5rem;
}
</style>
@@ -6,4 +6,4 @@
const currentUser = getCurrentAccount() as EmployeeAccount
</script>
<Documents query={{ responsible: currentUser.employee }} />
<Documents query={{ authors: currentUser.employee }} />
@@ -1,45 +0,0 @@
<!--
//
// Copyright © 2022 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 { WithLookup } from '@hcengineering/core'
import { Document, DocumentVersion } from '@hcengineering/document'
export let value: WithLookup<Document>
let lastVersion: DocumentVersion | undefined
$: if (value.$lookup?.versions !== undefined) {
let vv = -1
let dv: DocumentVersion | undefined
for (const v of value.$lookup.versions as DocumentVersion[]) {
if (v.version > vv) {
dv = v
vv = v.version
}
}
lastVersion = dv
}
</script>
{#if value}
<div class="flex-row-center">
{#if lastVersion}
{lastVersion.sequenceNumber}<span class="p-1">/</span>
{/if}
{value.editSequence}
</div>
{/if}
@@ -16,42 +16,28 @@
-->
<script lang="ts">
import { WithLookup } from '@hcengineering/core'
import { Document, DocumentVersion } from '@hcengineering/document'
import { DocumentVersion, DocumentVersionState } from '@hcengineering/document'
import { EastSideColor, FernColor, Label, SeaBuckthornColor } from '@hcengineering/ui'
import document from '../plugin'
export let value: WithLookup<Document>
let lastVersion: DocumentVersion | undefined
$: if (value.$lookup?.versions !== undefined) {
let vv = -1
let dv: DocumentVersion | undefined
for (const v of value.$lookup.versions as DocumentVersion[]) {
if (v.version > vv) {
dv = v
vv = v.version
}
}
lastVersion = dv
}
export let value: WithLookup<DocumentVersion>
</script>
{#if value}
<div class="status-container">
{#if value.versions === 0}
{#if value.state === DocumentVersionState.Draft}
<div class="status p-1" style:background-color={EastSideColor}>
<Label label={document.string.Draft} />
</div>
{/if}
{#if value.editSequence !== lastVersion?.sequenceNumber}
{#if value.state === DocumentVersionState.Approved}
<div class="status p-1" style:background-color={SeaBuckthornColor}>
<Label label={document.string.PendingReview} />
<Label label={document.string.Approved} />
</div>
{/if}
{#if value.editSequence === lastVersion?.sequenceNumber}
{#if value.state === DocumentVersionState.Rejected}
<div class="status p-1" style:background-color={FernColor}>
<Label label={document.string.Latest} />
<Label label={document.string.Rejected} />
</div>
{/if}
</div>