UBERF-6163 Store editor content as JSON (#5069)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2024-04-11 14:13:54 +07:00 committed by GitHub
parent 04095fcfc0
commit ed6fe769a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 2543 additions and 876 deletions

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,7 @@
"@hcengineering/model-view": "^0.6.0",
"@hcengineering/notification": "^0.6.16",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/text": "^0.6.1",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/view": "^0.6.9"
}

View File

@ -13,13 +13,18 @@
// limitations under the License.
//
import { type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
import { type DocUpdateMessage } from '@hcengineering/activity'
import core, { type Class, type Doc, type Domain, type Ref } from '@hcengineering/core'
import {
type MigrateOperation,
type MigrateUpdate,
type MigrationClient,
type MigrationDocumentQuery,
type MigrationIterator,
type MigrationUpgradeClient,
tryMigrate
} from '@hcengineering/model'
import { htmlToMarkup } from '@hcengineering/text'
import activity from './plugin'
import { activityId, DOMAIN_ACTIVITY } from './index'
@ -35,12 +40,89 @@ async function migrateReactions (client: MigrationClient): Promise<void> {
await client.move(DOMAIN_CHUNTER, { _class: activity.class.Reaction }, DOMAIN_ACTIVITY)
}
async function migrateMarkup (client: MigrationClient): Promise<void> {
const iterator = await client.traverse<DocUpdateMessage>(
DOMAIN_ACTIVITY,
{
_class: activity.class.DocUpdateMessage,
'attributeUpdates.attrClass': {
$in: [core.class.TypeMarkup, core.class.TypeCollaborativeMarkup]
}
},
{
projection: {
_id: 1,
attributeUpdates: 1
}
}
)
console.log('processing', activity.class.DocUpdateMessage)
try {
await processMigrateMarkupFor(client, iterator)
console.log('processing finished', activity.class.DocUpdateMessage)
} finally {
await iterator.close()
}
}
async function processMigrateMarkupFor (
client: MigrationClient,
iterator: MigrationIterator<DocUpdateMessage>
): Promise<void> {
let processed = 0
while (true) {
const docs = await iterator.next(1000)
if (docs === null || docs.length === 0) {
break
}
const ops: { filter: MigrationDocumentQuery<DocUpdateMessage>, update: MigrateUpdate<DocUpdateMessage> }[] = []
for (const doc of docs) {
if (doc.attributeUpdates == null) continue
if (doc.attributeUpdates.set == null) continue
if (doc.attributeUpdates.set.length === 0) continue
const update: MigrateUpdate<DocUpdateMessage> = {}
const attributeUpdatesSet = [...doc.attributeUpdates.set]
for (let i = 0; i < attributeUpdatesSet.length; i++) {
const value = attributeUpdatesSet[i]
if (value != null && typeof value === 'string') {
attributeUpdatesSet[i] = htmlToMarkup(value)
}
update['attributeUpdates.set'] = attributeUpdatesSet
}
const prevValue = doc.attributeUpdates.prevValue
if (prevValue != null && typeof prevValue === 'string') {
update['attributeUpdates.prevValue'] = htmlToMarkup(prevValue)
}
ops.push({ filter: { _id: doc._id }, update })
}
if (ops.length > 0) {
await client.bulk(DOMAIN_ACTIVITY, ops)
}
processed += docs.length
console.log('...processed', processed)
}
}
export const activityOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, activityId, [
{
state: 'reactions',
func: migrateReactions
},
{
state: 'markup',
func: migrateMarkup
}
])
},

View File

@ -41,6 +41,7 @@ import { timeOperation } from '@hcengineering/model-time'
import { activityOperation } from '@hcengineering/model-activity'
import { activityServerOperation } from '@hcengineering/model-server-activity'
import { documentOperation } from '@hcengineering/model-document'
import { textEditorOperation } from '@hcengineering/model-text-editor'
export const migrateOperations: [string, MigrateOperation][] = [
['core', coreOperation],
@ -68,6 +69,7 @@ export const migrateOperations: [string, MigrateOperation][] = [
['time', timeOperation],
['activityServer', activityServerOperation],
['document', documentOperation],
['textEditor', textEditorOperation],
// We should call it after activityServer and chunter
['notification', notificationOperation]
]

View File

@ -28,6 +28,7 @@ import {
type Doc,
type DocumentQuery,
type Domain,
type Markup,
type Ref,
type Timestamp,
type Tx
@ -41,8 +42,8 @@ import {
TypeBoolean,
TypeDate,
TypeIntlString,
TypeMarkup,
TypeRef,
TypeString,
UX,
type Builder
} from '@hcengineering/model'
@ -266,8 +267,8 @@ export class TCommonInboxNotification extends TInboxNotification implements Comm
headerIcon?: Asset
@Prop(TypeString(), notification.string.Message)
messageHtml?: string
@Prop(TypeMarkup(), notification.string.Message)
messageHtml?: Markup
props?: Record<string, any>
icon?: Asset

View File

@ -14,7 +14,7 @@
//
import type { Employee, Organization } from '@hcengineering/contact'
import { type Domain, IndexKind, type Ref, type Status, type Timestamp } from '@hcengineering/core'
import { type Domain, IndexKind, type Markup, type Ref, type Status, type Timestamp } from '@hcengineering/core'
import {
Collection,
Hidden,
@ -221,7 +221,7 @@ export class TOpinion extends TAttachedDoc implements Opinion {
comments?: number
@Prop(TypeMarkup(), recruit.string.Description)
description!: string
description!: Markup
@Prop(TypeString(), recruit.string.OpinionValue)
value!: string

View File

@ -14,8 +14,8 @@
// limitations under the License.
//
import { type Domain, DOMAIN_MODEL, IndexKind, type Ref } from '@hcengineering/core'
import { type Builder, Index, Model, Prop, TypeString, UX } from '@hcengineering/model'
import { type Domain, DOMAIN_MODEL, IndexKind, type Ref, type Markup } from '@hcengineering/core'
import { type Builder, Index, Model, Prop, TypeString, UX, TypeMarkup } from '@hcengineering/model'
import core, { TDoc, TSpace } from '@hcengineering/model-core'
import textEditor from '@hcengineering/model-text-editor'
import tracker from '@hcengineering/model-tracker'
@ -43,9 +43,9 @@ export class TMessageTemplate extends TDoc implements MessageTemplate {
@Index(IndexKind.FullText)
title!: string
@Prop(TypeString(), templates.string.Message)
@Prop(TypeMarkup(), templates.string.Message)
@Index(IndexKind.FullText)
message!: string
message!: Markup
}
@Model(templates.class.TemplateCategory, core.class.Space)

View File

@ -32,6 +32,7 @@
"@hcengineering/model": "^0.6.7",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/text": "^0.6.1",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/model-core": "^0.6.0"
}

View File

@ -21,6 +21,7 @@ import type { Asset, IntlString, Resource } from '@hcengineering/platform'
import { type RefInputAction, type RefInputActionItem } from '@hcengineering/text-editor/src/types'
import textEditor from './plugin'
export { textEditorOperation } from './migration'
export { textEditorId } from '@hcengineering/text-editor/src/plugin'
export { default } from './plugin'
export type { RefInputAction, RefInputActionItem }

View File

@ -0,0 +1,104 @@
//
// Copyright © 2024 Hardcore Engineering Inc
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import core, { type AnyAttribute, type Doc, type Domain } from '@hcengineering/core'
import {
type MigrateOperation,
type MigrateUpdate,
type MigrationClient,
type MigrationDocumentQuery,
type MigrationIterator,
type MigrationUpgradeClient,
tryMigrate
} from '@hcengineering/model'
import { htmlToMarkup } from '@hcengineering/text'
async function migrateMarkup (client: MigrationClient): Promise<void> {
const hierarchy = client.hierarchy
const classes = hierarchy.getDescendants(core.class.Doc)
for (const _class of classes) {
const domain = hierarchy.findDomain(_class)
if (domain === undefined) continue
const attributes = hierarchy.getAllAttributes(_class)
const filtered = Array.from(attributes.values()).filter((attribute) => {
return (
hierarchy.isDerived(attribute.type._class, core.class.TypeMarkup) ||
hierarchy.isDerived(attribute.type._class, core.class.TypeCollaborativeMarkup)
)
})
if (filtered.length === 0) continue
console.log('processing', _class, filtered.length, 'attributes')
const iterator = await client.traverse(domain, { _class })
try {
await processMigrateMarkupFor(domain, filtered, client, iterator)
console.log('processing finished', _class)
} finally {
await iterator.close()
}
}
}
async function processMigrateMarkupFor (
domain: Domain,
attributes: AnyAttribute[],
client: MigrationClient,
iterator: MigrationIterator<Doc>
): Promise<void> {
let processed = 0
while (true) {
const docs = await iterator.next(1000)
if (docs === null || docs.length === 0) {
break
}
const operations: { filter: MigrationDocumentQuery<Doc>, update: MigrateUpdate<Doc> }[] = []
for (const doc of docs) {
const update: MigrateUpdate<Doc> = {}
for (const attribute of attributes) {
const value = (doc as any)[attribute.name]
if (value != null) {
update[attribute.name] = htmlToMarkup(value)
}
}
if (Object.keys(update).length > 0) {
operations.push({ filter: { _id: doc._id }, update })
}
}
if (operations.length > 0) {
await client.bulk(domain, operations)
}
processed += docs.length
console.log('...processed', processed)
}
}
export const textEditorOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, 'text-editor', [
{
state: 'markup',
func: migrateMarkup
}
])
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {}
}

View File

@ -530,8 +530,8 @@ export interface DocIndexState extends Doc {
attributes: Record<string, any>
mixins?: Ref<Class<Doc>>[]
// Full Summary
fullSummary?: Markup | null
shortSummary?: Markup | null
fullSummary?: string | null
shortSummary?: string | null
}
/**

View File

@ -45,6 +45,7 @@
"@hcengineering/query": "^0.6.8",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/view": "^0.6.9",
"@hcengineering/text": "^0.6.1",
"svelte": "^4.2.12",
"@hcengineering/client": "^0.6.14",
"@hcengineering/collaborator-client": "^0.6.0",

View File

@ -0,0 +1,25 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { htmlToJSON } from '@hcengineering/text'
import Node from './markup/Node.svelte'
export let value: string
export let preview = false
$: node = htmlToJSON(value)
</script>
<Node {node} {preview} />

View File

@ -16,8 +16,7 @@
import { translate, type IntlString } from '@hcengineering/platform'
import { Button, FocusHandler, Label, createFocusManager } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import presentation from '..'
import MessageViewer from './MessageViewer.svelte'
import presentation, { HTMLViewer } from '..'
export let label: IntlString
export let labelProps: IntlString
@ -41,7 +40,7 @@
<div class="message">
{#if richMessage}
{#await translate(message, params) then msg}
<MessageViewer message={msg} />
<HTMLViewer value={msg} />
{/await}
{:else}
<Label label={message} {params} />

View File

@ -1,6 +1,6 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// Copyright © 2021, 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -14,21 +14,18 @@
// limitations under the License.
-->
<script lang="ts">
import Nodes from './message/Nodes.svelte'
import { isEmptyMarkup, markupToJSON } from '@hcengineering/text'
import Node from './markup/Node.svelte'
export let message: string
export let preview = false
let dom: HTMLElement
$: node = markupToJSON(message)
$: empty = isEmptyMarkup(message)
const parser = new DOMParser()
export function isEmpty () {
return doc.documentElement.innerText.length === 0
export function isEmpty (): boolean {
return empty
}
$: doc = parser.parseFromString(message, 'text/html')
$: dom = doc.firstChild?.childNodes[1] as HTMLElement
</script>
<Nodes nodes={dom.childNodes} {preview} />
<Node {node} {preview} />

View File

@ -0,0 +1,63 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { getMetadata } from '@hcengineering/platform'
import { MarkupMark, MarkupMarkType } from '@hcengineering/text'
import { navigate, parseLocation } from '@hcengineering/ui'
import presentation from '../../plugin'
export let mark: MarkupMark
function handleLink (e: MouseEvent): void {
try {
const href = mark.attrs.href
if (href != null && href !== '') {
const url = new URL(href)
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
if (url.origin === frontUrl) {
e.preventDefault()
navigate(parseLocation(url))
}
}
} catch (err) {
console.error('Failed to handle link', mark, err)
}
}
</script>
{#if mark}
{@const attrs = mark.attrs ?? {}}
{#if mark.type === MarkupMarkType.bold}
<strong><slot /></strong>
{:else if mark.type === MarkupMarkType.code}
<pre class="proseCode"><slot /></pre>
{:else if mark.type === MarkupMarkType.em}
<em><slot /></em>
{:else if mark.type === MarkupMarkType.link}
<a href={attrs.href} target={attrs.target} on:click={handleLink}>
<slot />
</a>
{:else if mark.type === MarkupMarkType.strike}
<s><slot /></s>
{:else if mark.type === MarkupMarkType.underline}
<u><slot /></u>
{:else}
unknown mark: "{mark.type}"
<slot />
{/if}
{/if}

View File

@ -0,0 +1,31 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { MarkupNode } from '@hcengineering/text'
import NodeMarks from './NodeMarks.svelte'
import NodeContent from './NodeContent.svelte'
export let node: MarkupNode
export let preview = false
</script>
{#if node}
{@const marks = node.marks ?? []}
<NodeMarks {marks}>
<NodeContent {node} {preview} />
</NodeMarks>
{/if}

View File

@ -0,0 +1,151 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, Ref } from '@hcengineering/core'
import { MarkupNode, MarkupNodeType } from '@hcengineering/text'
import MarkupNodes from './Nodes.svelte'
import ObjectNode from './ObjectNode.svelte'
export let node: MarkupNode
export let preview = false
function toRef (objectId: string): Ref<Doc> {
return objectId as Ref<Doc>
}
function toClassRef (objectClass: string): Ref<Class<Doc>> {
if (objectClass === 'contact:class:Employee') {
return 'contact:mixin:Employee' as Ref<Class<Doc>>
}
return objectClass as Ref<Class<Doc>>
}
function toString (value: string | number | undefined): string | undefined {
return value !== undefined ? `${value}` : undefined
}
function toNumber (value: string | number | undefined): number | undefined {
return value !== undefined ? (typeof value === 'string' ? parseInt(value) : value) : undefined
}
</script>
{#if node}
{@const attrs = node.attrs ?? {}}
{@const nodes = node.content ?? []}
{#if node.type === MarkupNodeType.doc}
<MarkupNodes {nodes} {preview} />
{:else if node.type === MarkupNodeType.text}
{node.text}
{:else if node.type === MarkupNodeType.paragraph}
<p class="p-inline contrast" class:overflow-label={preview}>
<MarkupNodes {nodes} {preview} />
</p>
{:else if node.type === MarkupNodeType.blockquote}
<blockquote class="proseBlockQuote" style:margin={preview ? '0' : null}>
<MarkupNodes {nodes} {preview} />
</blockquote>
{:else if node.type === MarkupNodeType.horizontal_rule}
<hr />
{:else if node.type === MarkupNodeType.heading}
{@const level = toNumber(node.attrs?.level) ?? 1}
{@const element = `h${level}`}
<svelte:element this={element}>
<MarkupNodes {nodes} {preview} />
</svelte:element>
{:else if node.type === MarkupNodeType.code_block}
<pre class="proseCodeBlock" style:margin={preview ? '0' : null}><code><MarkupNodes {nodes} {preview} /></code></pre>
{:else if node.type === MarkupNodeType.image}
{@const src = toString(attrs.src)}
{@const alt = toString(attrs.alt)}
{@const width = toString(attrs.width)}
{@const height = toString(attrs.height)}
<div class="imgContainer max-h-60 max-w-60">
<img {src} {alt} {width} {height} />
</div>
{:else if node.type === MarkupNodeType.reference}
{@const objectId = toString(attrs.id)}
{@const objectClass = toString(attrs.objectclass)}
{@const objectLabel = toString(attrs.label)}
{#if objectClass !== undefined && objectId !== undefined}
<ObjectNode _id={toRef(objectId)} _class={toClassRef(objectClass)} title={objectLabel} />
{:else}
<MarkupNodes {nodes} {preview} />
{/if}
{:else if node.type === MarkupNodeType.hard_break}
<br />
{:else if node.type === MarkupNodeType.ordered_list}
<ol style:margin={preview ? '0' : null}>
<MarkupNodes {nodes} {preview} />
</ol>
{:else if node.type === MarkupNodeType.bullet_list}
<ul style:margin={preview ? '0' : null}>
<MarkupNodes {nodes} {preview} />
</ul>
{:else if node.type === MarkupNodeType.list_item}
<li>
<MarkupNodes {nodes} {preview} />
</li>
{:else if node.type === MarkupNodeType.taskList}
<!-- TODO not implemented -->
{:else if node.type === MarkupNodeType.taskItem}
<!-- TODO not implemented -->
{:else if node.type === MarkupNodeType.sub}
<sub>
<MarkupNodes {nodes} {preview} />
</sub>
{:else if node.type === MarkupNodeType.table}
<table class="proseTable">
<tbody>
<MarkupNodes {nodes} {preview} />
</tbody>
</table>
{:else if node.type === MarkupNodeType.table_row}
<tr>
<MarkupNodes {nodes} {preview} />
</tr>
{:else if node.type === MarkupNodeType.table_cell}
{@const colspan = toNumber(attrs.colspan)}
{@const rowspan = toNumber(attrs.rowspan)}
<td {colspan} {rowspan}>
<MarkupNodes {nodes} {preview} />
</td>
{:else if node.type === MarkupNodeType.table_header}
{@const colspan = toNumber(attrs.colspan)}
{@const rowspan = toNumber(attrs.rowspan)}
<th {colspan} {rowspan}>
<MarkupNodes {nodes} {preview} />
</th>
{:else}
unknown node: "{node.type}"
<MarkupNodes {nodes} {preview} />
{/if}
{/if}
<style lang="scss">
.imgContainer {
display: inline;
}
.img {
:global(img) {
object-fit: contain;
height: 100%;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,39 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { MarkupMark } from '@hcengineering/text'
import Mark from './Mark.svelte'
export let marks: MarkupMark[]
</script>
{#if marks.length > 0}
{@const mark = marks[0]}
{@const others = marks.slice(1)}
{#if others.length > 0}
<Mark {mark}>
<svelte:self marks={others}>
<slot />
</svelte:self>
</Mark>
{:else}
<Mark {mark}>
<slot />
</Mark>
{/if}
{:else}
<slot />
{/if}

View File

@ -0,0 +1,27 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { MarkupNode } from '@hcengineering/text'
import Node from './Node.svelte'
export let nodes: MarkupNode[]
export let preview = false
</script>
{#if nodes}
{#each nodes as node}
<Node {node} {preview} />
{/each}
{/if}

View File

@ -1,193 +0,0 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 { CheckBox, navigate, parseLocation } from '@hcengineering/ui'
import { Class, Doc, Ref } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from '../../plugin'
import ObjectNode from './ObjectNode.svelte'
export let nodes: NodeListOf<any>
export let preview = false
function prevName (pos: number, nodes: NodeListOf<any>): string | undefined {
while (true) {
if (nodes[pos - 1]?.nodeName === '#text' && (nodes[pos - 1]?.data ?? '').trim() === '') {
pos--
continue
}
break
}
return nodes[pos - 1]?.nodeName
}
function handleLink (node: HTMLElement, e: MouseEvent) {
try {
const href = node.getAttribute('href')
if (href) {
const url = new URL(href)
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
if (url.origin === frontUrl) {
e.preventDefault()
navigate(parseLocation(url))
}
}
} catch {}
}
function correctClass (clName: string): Ref<Class<Doc>> {
if (clName === 'contact:class:Employee') {
return 'contact:mixin:Employee' as Ref<Class<Doc>>
}
return clName as Ref<Class<Doc>>
}
</script>
{#if nodes}
{#each nodes as node, ni}
{#if node.nodeType === Node.TEXT_NODE}
{node.data}
{:else if node.nodeName === 'EM'}
<em><svelte:self nodes={node.childNodes} {preview} /></em>
{:else if node.nodeName === 'STRONG' || node.nodeName === 'B'}
<strong><svelte:self nodes={node.childNodes} {preview} /></strong>
{:else if node.nodeName === 'U'}
<u><svelte:self nodes={node.childNodes} {preview} /></u>
{:else if node.nodeName === 'P'}
{#if node.childNodes.length > 0}
<p class="p-inline contrast" class:overflow-label={preview}>
<svelte:self nodes={node.childNodes} />
</p>
{/if}
{:else if node.nodeName === 'BLOCKQUOTE'}
<blockquote style:margin={preview ? '0' : null}><svelte:self nodes={node.childNodes} {preview} /></blockquote>
{:else if node.nodeName === 'CODE'}
<pre class="proseCode"><svelte:self nodes={node.childNodes} {preview} /></pre>
{:else if node.nodeName === 'PRE'}
<pre class="proseCodeBlock" style:margin={preview ? '0' : null}><svelte:self
nodes={node.childNodes}
{preview}
/></pre>
{:else if node.nodeName === 'BR'}
{@const pName = prevName(ni, nodes)}
{#if pName !== 'P' && pName !== 'BR' && pName !== undefined}
<br />
{/if}
{:else if node.nodeName === 'HR'}
<hr />
{:else if node.nodeName === 'IMG'}
<div class="imgContainer max-h-60 max-w-60">{@html node.outerHTML}</div>
{:else if node.nodeName === 'H1'}
<h1><svelte:self nodes={node.childNodes} {preview} /></h1>
{:else if node.nodeName === 'H2'}
<h2><svelte:self nodes={node.childNodes} {preview} /></h2>
{:else if node.nodeName === 'H3'}
<h3><svelte:self nodes={node.childNodes} {preview} /></h3>
{:else if node.nodeName === 'H4'}
<h4><svelte:self nodes={node.childNodes} {preview} /></h4>
{:else if node.nodeName === 'H5'}
<h5><svelte:self nodes={node.childNodes} {preview} /></h5>
{:else if node.nodeName === 'H6'}
<h6><svelte:self nodes={node.childNodes} {preview} /></h6>
{:else if node.nodeName === 'UL' || node.nodeName === 'LIST'}
<ul style:margin={preview ? '0' : null}><svelte:self nodes={node.childNodes} {preview} /></ul>
{:else if node.nodeName === 'OL' || node.nodeName === 'LIST=1'}
<ol style:margin={preview ? '0' : null}><svelte:self nodes={node.childNodes} {preview} /></ol>
{:else if node.nodeName === 'LI'}
<li class={node.className}><svelte:self nodes={node.childNodes} {preview} /></li>
{:else if node.nodeName === 'DIV'}
<div><svelte:self nodes={node.childNodes} {preview} /></div>
{:else if node.nodeName === 'A'}
<a
href={node.getAttribute('href')}
target={node.getAttribute('target')}
on:click={(e) => {
handleLink(node, e)
}}
>
<svelte:self nodes={node.childNodes} {preview} />
</a>
{:else if node.nodeName === 'LABEL'}
<svelte:self nodes={node.childNodes} {preview} />
{:else if node.nodeName === 'INPUT'}
{#if node.type?.toLowerCase() === 'checkbox'}
<div class="checkboxContainer">
<CheckBox readonly checked={node.checked} />
</div>
{/if}
{:else if node.nodeName === 'SPAN'}
{@const objectId = node.getAttribute('data-id')}
{@const objectClass = node.getAttribute('data-objectclass')}
{#if objectClass !== undefined && objectId !== undefined}
<ObjectNode _id={objectId} _class={correctClass(objectClass)} title={node.getAttribute('data-label')} />
{:else}
<svelte:self nodes={node.childNodes} {preview} />
{/if}
{:else if node.nodeName === 'TABLE'}
<table class={node.className}><svelte:self nodes={node.childNodes} {preview} /></table>
{:else if node.nodeName === 'TBODY'}
<tbody><svelte:self nodes={node.childNodes} {preview} /></tbody>
{:else if node.nodeName === 'TR'}
<tr><svelte:self nodes={node.childNodes} {preview} /></tr>
{:else if node.nodeName === 'TH'}
<th><svelte:self nodes={node.childNodes} {preview} /></th>
{:else if node.nodeName === 'TD'}
<td><svelte:self nodes={node.childNodes} {preview} /></td>
{:else if node.nodeName === 'S'}
<s><svelte:self nodes={node.childNodes} {preview} /></s>
{:else}
unknown: "{node.nodeName}"
<svelte:self nodes={node.childNodes} {preview} />
{/if}
{/each}
{/if}
<style lang="scss">
.imgContainer {
display: inline;
}
.img {
:global(img) {
object-fit: contain;
height: 100%;
width: 100%;
}
}
.checkboxContainer {
padding-top: 0.125rem;
}
em,
strong,
blockquote,
pre,
h1,
h2,
h3,
h4,
h5,
h6,
ol,
li,
.checkboxContainer,
s {
color: var(--global-primary-TextColor);
}
</style>

View File

@ -23,6 +23,7 @@ export { default as InlineAttributeBarEditor } from './components/InlineAttribut
export { default as InlineAttributeBar } from './components/InlineAttributeBar.svelte'
export { default as AttributesBar } from './components/AttributesBar.svelte'
export { default as Card } from './components/Card.svelte'
export { default as HTMLViewer } from './components/HTMLViewer.svelte'
export { default as MessageBox } from './components/MessageBox.svelte'
export { default as MessageViewer } from './components/MessageViewer.svelte'
export { default as ObjectPopup } from './components/ObjectPopup.svelte'

View File

@ -47,31 +47,31 @@
"@hcengineering/text": "^0.6.1",
"@hcengineering/collaborator-client": "^0.6.0",
"svelte": "^4.2.12",
"@tiptap/core": "^2.1.12",
"@tiptap/pm": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"@tiptap/extension-highlight": "^2.1.12",
"@tiptap/extension-placeholder": "^2.1.12",
"@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-typography": "^2.1.12",
"@tiptap/extension-link": "^2.1.12",
"@tiptap/extension-task-list": "^2.1.12",
"@tiptap/extension-task-item": "^2.1.12",
"@tiptap/extension-collaboration": "^2.1.12",
"@tiptap/extension-collaboration-cursor": "^2.1.12",
"@tiptap/extension-code-block": "^2.1.12",
"@tiptap/extension-gapcursor": "^2.1.12",
"@tiptap/extension-heading": "^2.1.12",
"@tiptap/extension-history": "^2.1.12",
"@tiptap/extension-table": "^2.1.12",
"@tiptap/extension-table-cell": "^2.1.12",
"@tiptap/extension-table-header": "^2.1.12",
"@tiptap/extension-table-row": "^2.1.12",
"@tiptap/extension-code": "^2.1.12",
"@tiptap/extension-bubble-menu": "^2.1.12",
"@tiptap/extension-underline": "^2.1.12",
"@tiptap/extension-list-keymap": "^2.1.12",
"@tiptap/core": "^2.2.4",
"@tiptap/pm": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4",
"@tiptap/suggestion": "^2.2.4",
"@tiptap/extension-highlight": "^2.2.4",
"@tiptap/extension-placeholder": "^2.2.4",
"@tiptap/extension-mention": "^2.2.4",
"@tiptap/extension-typography": "^2.2.4",
"@tiptap/extension-link": "^2.2.4",
"@tiptap/extension-task-list": "^2.2.4",
"@tiptap/extension-task-item": "^2.2.4",
"@tiptap/extension-collaboration": "^2.2.4",
"@tiptap/extension-collaboration-cursor": "^2.2.4",
"@tiptap/extension-code-block": "^2.2.4",
"@tiptap/extension-gapcursor": "^2.2.4",
"@tiptap/extension-heading": "^2.2.4",
"@tiptap/extension-history": "^2.2.4",
"@tiptap/extension-table": "^2.2.4",
"@tiptap/extension-table-cell": "^2.2.4",
"@tiptap/extension-table-header": "^2.2.4",
"@tiptap/extension-table-row": "^2.2.4",
"@tiptap/extension-code": "^2.2.4",
"@tiptap/extension-bubble-menu": "^2.2.4",
"@tiptap/extension-underline": "^2.2.4",
"@tiptap/extension-list-keymap": "^2.2.4",
"@hocuspocus/provider": "^2.9.0",
"prosemirror-codemark": "^0.4.2",
"yjs": "^13.5.52",
@ -80,6 +80,7 @@
"rfc6902": "^5.0.1",
"diff": "^5.1.0",
"slugify": "^1.6.6",
"lib0": "^0.2.88"
"lib0": "^0.2.88",
"fast-equals": "^2.0.3"
}
}

View File

@ -15,6 +15,7 @@
//
-->
<script lang="ts">
import { MarkupNode } from '@hcengineering/text'
import { onDestroy, onMount } from 'svelte'
import { Doc as Ydoc } from 'yjs'
@ -38,7 +39,7 @@
let editor: Editor
let _decoration = DecorationSet.empty
let oldContent = ''
let oldContent: MarkupNode | undefined
function updateEditor (editor: Editor, ydoc: Ydoc, field?: string): void {
const r = calculateDecorations(editor, oldContent, createYdocDocument(editor.schema, ydoc, field))

View File

@ -19,6 +19,7 @@
import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client'
import { IntlString, getMetadata, translate } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { markupToJSON } from '@hcengineering/text'
import { Button, IconSize, Loading, themeStore } from '@hcengineering/ui'
import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
import Collaboration, { isChangeOrigin } from '@tiptap/extension-collaboration'
@ -155,8 +156,11 @@
insertText: (text) => {
editor?.commands.insertContent(text)
},
insertTemplate: (name, text) => {
editor?.commands.insertContent(text)
insertMarkup: (markup) => {
editor?.commands.insertContent(markupToJSON(markup))
},
insertTemplate: (name, markup) => {
editor?.commands.insertContent(markupToJSON(markup))
},
focus: () => {
focus()

View File

@ -15,6 +15,7 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Markup } from '@hcengineering/core'
import { IntlString, Asset } from '@hcengineering/platform'
import { Label, Icon } from '@hcengineering/ui'
import type { AnySvelteComponent } from '@hcengineering/ui'
@ -24,7 +25,7 @@
export let label: IntlString = textEditorPlugin.string.FullDescription
export let icon: Asset | AnySvelteComponent = IconDescription
export let content: string = ''
export let content: Markup = ''
export let maxHeight: string = '40vh'
export let enableBackReferences = false

View File

@ -19,30 +19,31 @@
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import { onDestroy, onMount } from 'svelte'
import { Markup } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { MarkupNode, ReferenceNode, jsonToPmNode } from '@hcengineering/text'
import { calculateDecorations, createMarkupDocument } from './diff/decorations'
import { calculateDecorations } from './diff/decorations'
import { defaultEditorAttributes } from './editor/editorProps'
import { ImageExtension } from './extension/imageExt'
import { EditorKit } from '../kits/editor-kit'
export let content: Markup
export let comparedVersion: Markup | undefined = undefined
export let content: MarkupNode
export let comparedVersion: MarkupNode | undefined = undefined
let element: HTMLElement
let editor: Editor
let _decoration = DecorationSet.empty
let oldContent = ''
let oldContent: MarkupNode | undefined
function updateEditor (editor: Editor, comparedVersion?: Markup): void {
function updateEditor (editor: Editor, comparedVersion?: MarkupNode): void {
if (comparedVersion === undefined) {
return
}
const r = calculateDecorations(editor, oldContent, createMarkupDocument(editor.schema, comparedVersion))
const node = jsonToPmNode(comparedVersion, editor.schema)
const r = calculateDecorations(editor, oldContent, node)
if (r !== undefined) {
oldContent = r.oldContent
_decoration = r.decorations
@ -83,6 +84,7 @@
editable: false,
extensions: [
EditorKit,
ReferenceNode,
ImageExtension.configure({
uploadUrl: getMetadata(presentation.metadata.UploadURL)
}),

View File

@ -13,6 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Markup } from '@hcengineering/core'
import { Asset, IntlString } from '@hcengineering/platform'
import {
AnySvelteComponent,
@ -36,7 +37,7 @@
import { IsEmptyContentExtension } from './extension/isEmptyContent'
import Send from './icons/Send.svelte'
export let content: string = ''
export let content: Markup = ''
export let showHeader = false
export let showActions = true
export let showSend = true
@ -62,7 +63,7 @@
$: devSize = $deviceInfo.size
$: shrinkButtons = checkAdaptiveMatching(devSize, 'sm')
function setContent (content: string): void {
function setContent (content: Markup): void {
textEditor?.setContent(content)
}
@ -70,8 +71,11 @@
insertText: (text) => {
textEditor?.insertText(text)
},
insertTemplate: (name, text) => {
textEditor?.insertText(text)
insertMarkup: (markup) => {
textEditor?.insertMarkup(markup)
},
insertTemplate: (name, markup) => {
textEditor?.insertMarkup(markup)
},
focus: () => {
textEditor?.focus()

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { AnyExtension } from '@tiptap/core'
import { Markup } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { ButtonSize, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
@ -21,8 +22,8 @@
export let required = false
export let enableBackReferences = false
let rawValue: string
let oldContent = ''
let rawValue: Markup
let oldContent: Markup = ''
$: if (content !== undefined && oldContent !== content) {
oldContent = content

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { Markup } from '@hcengineering/core'
import { IntlString, getMetadata } from '@hcengineering/platform'
import presentation, { MessageViewer } from '@hcengineering/presentation'
import {
@ -27,7 +28,7 @@
import { RefAction } from '../types'
export let label: IntlString | undefined = undefined
export let content: string
export let content: Markup
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let kind: 'normal' | 'emphasized' | 'indented' = 'normal'
@ -74,8 +75,8 @@
let canBlur = true
let focused = false
let rawValue: string
let oldContent = ''
let rawValue: Markup
let oldContent: Markup = ''
let modified: boolean = false
let textEditor: StyledTextEditor

View File

@ -21,6 +21,7 @@
import { RefAction, TextEditorHandler, TextFormatCategory } from '../types'
import { defaultRefActions, getModelRefActions } from './editor/actions'
import TextEditor from './TextEditor.svelte'
import { Markup } from '@hcengineering/core'
const dispatch = createEventDispatcher()
@ -63,10 +64,10 @@
export function setEditable (editable: boolean): void {
textEditor?.setEditable(editable)
}
export function getContent (): string {
export function getContent (): Markup {
return content
}
export function setContent (data: string): void {
export function setContent (data: Markup): void {
textEditor?.setContent(data)
}
export function insertText (text: string): void {
@ -86,8 +87,11 @@
insertText: (text) => {
textEditor?.insertText(text)
},
insertTemplate: (name, text) => {
textEditor?.insertText(text)
insertMarkup: (markup) => {
textEditor?.insertMarkup(markup)
},
insertTemplate: (name, markup) => {
textEditor?.insertMarkup(markup)
dispatch('template', name)
},
focus: () => {

View File

@ -14,7 +14,9 @@
// limitations under the License.
-->
<script lang="ts">
import { Markup } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { EmptyMarkup, getMarkup, markupToJSON } from '@hcengineering/text'
import { themeStore } from '@hcengineering/ui'
import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
@ -33,7 +35,7 @@
import { SubmitExtension } from './extension/submit'
import { EditorKit } from '../kits/editor-kit'
export let content: string = ''
export let content: Markup = ''
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let extensions: AnyExtension[] = []
export let textFormatCategories: TextFormatCategory[] = []
@ -62,24 +64,32 @@
}
}
export function submit (): void {
content = editor.getHTML()
content = getContent()
dispatch('content', content)
}
export function setContent (newContent: string): void {
export function getContent (): Markup {
return getMarkup(editor)
}
export function setContent (newContent: Markup): void {
if (content !== newContent) {
content = newContent
editor.commands.setContent(content)
editor.commands.setContent(markupToJSON(content))
}
}
export function clear (): void {
content = ''
content = EmptyMarkup
editor.commands.clearContent(true)
}
export function insertText (text: string): void {
editor.commands.insertContent(text)
}
export function insertMarkup (markup: Markup): void {
editor.commands.insertContent(markupToJSON(markup))
}
let needFocus = false
let focused = false
let posFocus: FocusPosition | undefined = undefined
@ -123,7 +133,7 @@
editor = new Editor({
element,
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes) },
content,
content: markupToJSON(content),
autofocus,
extensions: [
EditorKit,
@ -169,7 +179,7 @@
dispatch('focus')
},
onUpdate: () => {
content = editor.getHTML()
content = getContent()
dispatch('value', content)
dispatch('update', content)
}

View File

@ -13,19 +13,20 @@
// limitations under the License.
//
import { type Markup } from '@hcengineering/core'
import { type MarkupNode } from '@hcengineering/text'
import { type Editor } from '@tiptap/core'
import { ChangeSet } from '@tiptap/pm/changeset'
import { DOMParser, type Node, type Schema } from '@tiptap/pm/model'
import { type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { deepEqual } from 'fast-equals'
import { yDocToProsemirrorJSON } from 'y-prosemirror'
import { Doc as Ydoc, applyUpdate } from 'yjs'
import { type Doc as Ydoc } from 'yjs'
import { recreateTransform } from './recreate'
/**
* @public
*/
export function createYdocDocument (schema: Schema, ydoc: Ydoc, field?: string): Node {
export function createYdocDocument (schema: Schema, ydoc: Ydoc, field?: string): ProseMirrorNode {
try {
const body = yDocToProsemirrorJSON(ydoc, field)
return schema.nodeFromJSON(body)
@ -35,42 +36,17 @@ export function createYdocDocument (schema: Schema, ydoc: Ydoc, field?: string):
}
}
/**
* @public
*/
export function createMarkupDocument (schema: Schema, content: Markup | ArrayBuffer, field?: string): Node {
if (typeof content === 'string') {
const wrappedValue = `<body>${content}</body>`
const body = new window.DOMParser().parseFromString(wrappedValue, 'text/html').body
return DOMParser.fromSchema(schema).parse(body)
} else {
try {
const ydoc = new Ydoc()
const uint8arr = new Uint8Array(content)
applyUpdate(ydoc, uint8arr)
const body = yDocToProsemirrorJSON(ydoc, field)
return schema.nodeFromJSON(body)
} catch (err: any) {
console.error(err)
return schema.node(schema.topNodeType)
}
}
}
/**
* @public
*/
export function calculateDecorations (
editor?: Editor,
oldContent?: string,
comparedDoc?: Node
oldContent?: MarkupNode,
comparedDoc?: ProseMirrorNode
):
| {
decorations: DecorationSet
oldContent: string
oldContent: MarkupNode
}
| undefined {
try {
@ -82,8 +58,8 @@ export function calculateDecorations (
}
const docNew = editor.state.doc
const c = editor.getHTML()
if (c === oldContent) {
const c = editor.getJSON() as MarkupNode
if (deepEqual(c, oldContent)) {
return
}

View File

@ -40,6 +40,15 @@ export { default } from './plugin'
export * from './types'
export * from './utils'
export {
EmptyMarkup,
areEqualMarkups,
getMarkup,
isEmptyMarkup,
pmNodeToMarkup,
markupToPmNode
} from '@hcengineering/text'
export { FocusExtension, type FocusOptions, type FocusStorage } from './components/extension/focus'
export { HeadingsExtension, type HeadingsOptions, type HeadingsStorage } from './components/extension/headings'
export {

View File

@ -20,6 +20,7 @@ import type { Level } from '@tiptap/extension-heading'
import Highlight from '@tiptap/extension-highlight'
import Link from '@tiptap/extension-link'
import Typography from '@tiptap/extension-typography'
import Underline from '@tiptap/extension-underline'
import StarterKit from '@tiptap/starter-kit'
export interface DefaultKitOptions {
@ -41,11 +42,17 @@ export const DefaultKit = Extension.create<DefaultKitOptions>({
addExtensions () {
return [
StarterKit.configure({
blockquote: {
HTMLAttributes: {
class: 'proseBlockQuote'
}
},
code: codeOptions,
codeBlock: this.options.codeBlock ?? codeBlockOptions,
heading: this.options.heading,
history: this.options.history
}),
Underline,
Highlight.configure({
multicolor: false
}),

View File

@ -19,7 +19,6 @@ import ListKeymap from '@tiptap/extension-list-keymap'
import TableHeader from '@tiptap/extension-table-header'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import Underline from '@tiptap/extension-underline'
import { DefaultKit, type DefaultKitOptions } from './default-kit'
@ -70,7 +69,6 @@ export const EditorKit = Extension.create<EditorKitOptions>({
}),
CodeBlockExtension.configure(codeBlockOptions),
CodemarkExtension,
Underline,
ListKeymap.configure({
listTypes: [
{

View File

@ -1,5 +1,5 @@
import { type Asset, type IntlString, type Resource } from '@hcengineering/platform'
import { type Account, type Doc, type Ref } from '@hcengineering/core'
import { type Account, type Doc, type Markup, type Ref } from '@hcengineering/core'
import type { AnySvelteComponent } from '@hcengineering/ui'
import { type Editor, type SingleCommands } from '@tiptap/core'
@ -8,7 +8,8 @@ import { type Editor, type SingleCommands } from '@tiptap/core'
*/
export interface TextEditorHandler {
insertText: (html: string) => void
insertTemplate: (name: string, html: string) => void
insertMarkup: (markup: Markup) => void
insertTemplate: (name: string, markup: string) => void
focus: () => void
}
/**

View File

@ -28,31 +28,34 @@
"prettier": "^3.1.0",
"typescript": "^5.3.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/core": "^0.6.28",
"@tiptap/core": "^2.1.12",
"@tiptap/html": "^2.1.12",
"@tiptap/pm": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/extension-gapcursor": "^2.1.12",
"@tiptap/extension-heading": "^2.1.12",
"@tiptap/extension-highlight": "^2.1.12",
"@tiptap/extension-history": "^2.1.12",
"@tiptap/extension-link": "^2.1.12",
"@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-table": "^2.1.12",
"@tiptap/extension-table-cell": "^2.1.12",
"@tiptap/extension-table-header": "^2.1.12",
"@tiptap/extension-table-row": "^2.1.12",
"@tiptap/extension-task-item": "^2.1.12",
"@tiptap/extension-task-list": "^2.1.12",
"@tiptap/extension-typography": "^2.1.12",
"@tiptap/extension-code-block": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"prosemirror-model": "^1.19.2",
"@tiptap/core": "^2.2.4",
"@tiptap/html": "^2.2.4",
"@tiptap/pm": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4",
"@tiptap/extension-gapcursor": "^2.2.4",
"@tiptap/extension-heading": "^2.2.4",
"@tiptap/extension-highlight": "^2.2.4",
"@tiptap/extension-history": "^2.2.4",
"@tiptap/extension-link": "^2.2.4",
"@tiptap/extension-mention": "^2.2.4",
"@tiptap/extension-table": "^2.2.4",
"@tiptap/extension-table-cell": "^2.2.4",
"@tiptap/extension-table-header": "^2.2.4",
"@tiptap/extension-table-row": "^2.2.4",
"@tiptap/extension-task-item": "^2.2.4",
"@tiptap/extension-task-list": "^2.2.4",
"@tiptap/extension-typography": "^2.2.4",
"@tiptap/extension-code-block": "^2.2.4",
"@tiptap/extension-underline": "^2.2.4",
"@tiptap/suggestion": "^2.2.4",
"prosemirror-model": "^1.19.4",
"fast-equals": "^2.0.3",
"yjs": "^13.5.52",
"y-prosemirror": "^1.2.1"
},

View File

@ -13,11 +13,6 @@
// limitations under the License.
//
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { ServerKit } from './kits/server-kit'
/**
* @public
*/
export function getText (node: ProseMirrorNode): string {
return node.textBetween(0, node.content.size, '\n', '')
}
export const defaultExtensions = [ServerKit]

View File

@ -1,86 +0,0 @@
//
// 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 { Extensions, getSchema } from '@tiptap/core'
import { generateJSON, generateHTML } from '@tiptap/html'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { ServerKit } from './kits/server-kit'
const defaultExtensions = [ServerKit]
/**
* @public
*/
export function getHTML (node: ProseMirrorNode, extensions: Extensions): string {
return generateHTML(node.toJSON(), extensions)
}
/**
* @public
*/
export function parseHTML (content: string, extensions?: Extensions): ProseMirrorNode {
extensions = extensions ?? defaultExtensions
const schema = getSchema(extensions)
const json = generateJSON(content, extensions)
return ProseMirrorNode.fromJSON(schema, json)
}
const ELLIPSIS_CHAR = '…'
const WHITESPACE = ' '
/**
* @public
*/
export function stripTags (htmlString: string, textLimit = 0, extensions: Extensions | undefined = undefined): string {
const effectiveExtensions = extensions ?? defaultExtensions
const parsed = parseHTML(htmlString, effectiveExtensions)
const textParts: string[] = []
let charCount = 0
let isHardStop = false
parsed.descendants((node, _pos, parent): boolean => {
if (isHardStop) {
return false
}
if (node.type.isText) {
const text = node.text ?? ''
if (textLimit > 0 && charCount + text.length > textLimit) {
const toAddCount = textLimit - charCount
const textPart = text.substring(0, toAddCount)
textParts.push(textPart)
textParts.push(ELLIPSIS_CHAR)
isHardStop = true
} else {
textParts.push(text)
charCount += text.length
}
return false
} else if (node.type.isBlock) {
if (textParts.length > 0 && textParts[textParts.length - 1] !== WHITESPACE) {
textParts.push(WHITESPACE)
charCount++
}
}
return true
})
const result = textParts.join('')
return result
}

View File

@ -13,10 +13,13 @@
// limitations under the License.
//
export * from './html'
export * from './node'
export * from './extensions'
export * from './markup/dsl'
export * from './markup/model'
export * from './markup/traverse'
export * from './markup/utils'
export * from './nodes'
export * from './text'
export * from './ydoc'
export * from './kits/default-kit'
export * from './kits/server-kit'

View File

@ -19,6 +19,7 @@ import { Level } from '@tiptap/extension-heading'
import Highlight from '@tiptap/extension-highlight'
import Link from '@tiptap/extension-link'
import Typography from '@tiptap/extension-typography'
import Underline from '@tiptap/extension-underline'
import StarterKit, { StarterKitOptions } from '@tiptap/starter-kit'
export interface DefaultKitOptions {
@ -49,10 +50,16 @@ export const DefaultKit = Extension.create<DefaultKitOptions>({
class: 'proseCode'
}
},
blockquote: {
HTMLAttributes: {
class: 'proseBlockQuote'
}
},
codeBlock,
heading: this.options.heading,
history: this.options.history
}),
Underline,
Highlight.configure({
multicolor: false
}),

View File

@ -0,0 +1,98 @@
import { nodeDoc, nodeImage, nodeParagraph, nodeReference, nodeText, markLink, markUnderline } from '../dsl'
import { MarkupNodeType } from '../model'
import { jsonToHTML } from '../utils'
describe('dsl', () => {
it('returns a MarkupNode for complex doc', () => {
const doc = nodeDoc(
nodeParagraph(nodeText('Hello, '), nodeReference({ id: '123', label: 'World', objectclass: 'world' })),
nodeParagraph(
nodeText('Check out '),
markLink({ href: 'https://example.com', title: 'this link' }, markUnderline(nodeText('this link'))),
nodeText('.')
)
)
expect(jsonToHTML(doc)).toEqual(
'<p>Hello, <span data-type="reference" data-id="123" data-objectclass="world" data-label="World">@World</span></p><p>Check out <a target="_blank" rel="noopener noreferrer" class="cursor-pointer" href="https://example.com"><u>this link</u></a>.</p>'
)
})
})
describe('nodeDoc', () => {
it('returns a MarkupNode with type "doc"', () => {
const result = nodeDoc()
expect(result.type).toEqual(MarkupNodeType.doc)
})
it('returns a MarkupNode with the provided content', () => {
const content = [
{ type: MarkupNodeType.paragraph, content: [{ type: MarkupNodeType.text, text: 'Hello' }] },
{ type: MarkupNodeType.paragraph, content: [{ type: MarkupNodeType.text, text: 'World' }] }
]
const result = nodeDoc(...content)
expect(result.content).toEqual(content)
})
it('returns an empty MarkupNode if no content is provided', () => {
const result = nodeDoc()
expect(result.content).toEqual([])
})
})
describe('nodeParagraph', () => {
it('returns a MarkupNode with type "paragraph"', () => {
const result = nodeParagraph()
expect(result.type).toEqual(MarkupNodeType.paragraph)
})
it('returns a MarkupNode with the provided content', () => {
const content = [{ type: MarkupNodeType.text, text: 'Hello' }]
const result = nodeParagraph(...content)
expect(result.content).toEqual(content)
})
it('returns an empty MarkupNode if no content is provided', () => {
const result = nodeParagraph()
expect(result.content).toEqual([])
})
})
describe('nodeText', () => {
it('returns a MarkupNode with type "text"', () => {
const result = nodeText('Hello')
expect(result.type).toEqual(MarkupNodeType.text)
})
it('returns a MarkupNode with the provided text', () => {
const result = nodeText('Hello')
expect(result.text).toEqual('Hello')
})
})
describe('nodeImage', () => {
it('returns a MarkupNode with type "image"', () => {
const attrs = { src: 'image.jpg' }
const result = nodeImage(attrs)
expect(result.type).toEqual(MarkupNodeType.image)
})
it('returns a MarkupNode with the provided attributes', () => {
const attrs = { src: 'image.jpg', alt: 'Image', width: 500, height: 300 }
const result = nodeImage(attrs)
expect(result.attrs).toEqual(attrs)
})
})
describe('nodeReference', () => {
it('returns a MarkupNode with type "reference"', () => {
const attrs = { id: '123', label: 'Reference', objectclass: 'class' }
const result = nodeReference(attrs)
expect(result.type).toEqual(MarkupNodeType.reference)
})
it('returns a MarkupNode with the provided attributes', () => {
const attrs = { id: '123', label: 'Reference', objectclass: 'class' }
const result = nodeReference(attrs)
expect(result.attrs).toEqual(attrs)
})
})

View File

@ -0,0 +1,97 @@
import { MarkupNode, MarkupNodeType } from '../model'
import { traverseAllMarks, traverseNode, traverseNodeMarks } from '../traverse'
describe('traverseNode', () => {
it('should call the callback function for each node', () => {
const callback = jest.fn()
const node = {
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hello, world!'
}
]
}
traverseNode(node as MarkupNode, callback)
expect(callback).toHaveBeenCalledTimes(2)
expect(callback).toHaveBeenCalledWith(node)
expect(callback).toHaveBeenCalledWith(node.content[0])
})
it('should stop traversing if the callback returns false', () => {
const callback = jest.fn((node) => {
if (node.type === MarkupNodeType.paragraph) {
return false
}
})
const node = {
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.text,
text: 'Hello, world!'
}
]
}
traverseNode(node, callback)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(node)
})
})
describe('traverseNodeMarks', () => {
it('should call the callback function for each mark', () => {
const callback = jest.fn()
const node = {
type: 'paragraph',
marks: [{ type: 'bold' }, { type: 'italic' }, { type: 'underline' }]
}
traverseNodeMarks(node as MarkupNode, callback)
expect(callback).toHaveBeenCalledTimes(3)
expect(callback).toHaveBeenCalledWith(node.marks[0])
expect(callback).toHaveBeenCalledWith(node.marks[1])
expect(callback).toHaveBeenCalledWith(node.marks[2])
})
it('should not call the callback function if marks are not present', () => {
const callback = jest.fn()
const node = {
type: MarkupNodeType.paragraph
}
traverseNodeMarks(node, callback)
expect(callback).not.toHaveBeenCalled()
})
})
describe('traverseAllMarks', () => {
it('should traverse all marks and call the callback function', () => {
const callback = jest.fn()
const node = {
type: 'paragraph',
marks: [{ type: 'bold' }],
content: [
{
type: MarkupNodeType.text,
text: 'Hello, world!',
marks: [{ type: 'italic' }, { type: 'underline' }]
}
]
}
traverseAllMarks(node as MarkupNode, callback)
expect(callback).toHaveBeenCalledTimes(3)
expect(callback).toHaveBeenCalledWith(node, node.marks[0])
expect(callback).toHaveBeenCalledWith(node.content[0], node.content[0].marks[0])
expect(callback).toHaveBeenCalledWith(node.content[0], node.content[0].marks[1])
})
})

View File

@ -0,0 +1,338 @@
/**
* @jest-environment jsdom
*/
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Editor, getSchema } from '@tiptap/core'
import { MarkupMarkType, MarkupNode, MarkupNodeType } from '../model'
import {
EmptyMarkup,
areEqualMarkups,
getMarkup,
htmlToJSON,
htmlToMarkup,
htmlToPmNode,
isEmptyMarkup,
jsonToHTML,
jsonToMarkup,
jsonToText,
markupToHTML,
markupToJSON,
markupToPmNode,
pmNodeToHTML,
pmNodeToJSON,
pmNodeToMarkup
} from '../utils'
import { ServerKit } from '../../kits/server-kit'
import { nodeDoc, nodeParagraph, nodeText } from '../dsl'
// mock tiptap functions
jest.mock('@tiptap/html', () => ({
generateHTML: jest.fn(() => '<p>hello</p>'),
generateJSON: jest.fn(() => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }]
}))
}))
const extensions = [ServerKit]
describe('EmptyMarkup', () => {
it('is empty markup', async () => {
const editor = new Editor({ extensions })
expect(getMarkup(editor)).toEqual(EmptyMarkup)
})
})
describe('getMarkup', () => {
it('with empty content', async () => {
const editor = new Editor({ extensions })
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph"}]}')
})
it('with some content', async () => {
const editor = new Editor({ extensions, content: '<p>hello</p>' })
expect(getMarkup(editor)).toEqual(
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
)
})
it('with empty paragraphs as content', async () => {
const editor = new Editor({ extensions, content: '<p></p><p></p>' })
expect(getMarkup(editor)).toEqual('{"type":"doc","content":[{"type":"paragraph"},{"type":"paragraph"}]}')
})
})
describe('isEmptyMarkup', () => {
it('returns true for undefined content', async () => {
expect(isEmptyMarkup(undefined)).toBeTruthy()
expect(isEmptyMarkup('')).toBeTruthy()
})
it('returns true for empty content', async () => {
const editor = new Editor({ extensions })
expect(isEmptyMarkup(getMarkup(editor))).toBeTruthy()
})
it('returns true for empty paragraphs content', async () => {
const editor = new Editor({ extensions, content: '<p></p><p></p><p></p>' })
expect(isEmptyMarkup(getMarkup(editor))).toBeTruthy()
})
it('returns true for empty paragraphs content with spaces', async () => {
const editor = new Editor({ extensions, content: '<p> </p><p> </p><p> </p>' })
expect(isEmptyMarkup(getMarkup(editor))).toBeTruthy()
})
it('returns false for not empty content', async () => {
const editor = new Editor({ extensions, content: '<p>hello</p>' })
expect(isEmptyMarkup(getMarkup(editor))).toBeFalsy()
})
})
describe('areEqualMarkups', () => {
it('returns true for the same content', async () => {
const markup = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
expect(areEqualMarkups(markup, markup)).toBeTruthy()
})
it('returns true for the same content with different spaces', async () => {
const markup1 = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
const markup2 =
'{"type":"doc","content":[{"type":"hardBreak"},{"type":"paragraph","content":[{"type":"text","text":"hello"}]},{"type":"hardBreak"}]}'
expect(areEqualMarkups(markup1, markup2)).toBeTruthy()
})
it('returns false for different content', async () => {
const markup1 = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
const markup2 = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"world"}]}]}'
expect(areEqualMarkups(markup1, markup2)).toBeFalsy()
})
})
describe('pmNodeToMarkup', () => {
it('converts ProseMirrorNode to Markup', () => {
const schema = getSchema(extensions)
const node = schema.node('paragraph', {}, [schema.text('Hello, world!')])
expect(pmNodeToMarkup(node)).toEqual('{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}')
})
})
describe('markupToPmNode', () => {
it('converts markup to ProseMirrorNode', () => {
const markup = '{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}'
const node = markupToPmNode(markup)
expect(node.type.name).toEqual('paragraph')
expect(node.content.childCount).toEqual(1)
expect(node.content.child(0).type.name).toEqual('text')
expect(node.content.child(0).text).toEqual('Hello, world!')
})
})
describe('markupToJSON', () => {
it('with empty content', async () => {
expect(markupToJSON('')).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] })
})
it('with some content', async () => {
const markup = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
expect(markupToJSON(markup)).toEqual({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }]
})
})
})
describe('jsonToMarkup', () => {
it('with some content', async () => {
const json: MarkupNode = {
type: MarkupNodeType.doc,
content: [
{
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.text,
text: 'hello'
}
]
}
]
}
expect(jsonToMarkup(json)).toEqual(
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
)
})
})
describe('pmNodeToJSON', () => {
it('converts ProseMirrorNode to Markup', () => {
const schema = getSchema(extensions)
const node = schema.node('paragraph', {}, [schema.text('Hello, world!')])
const json = nodeParagraph(nodeText('Hello, world!'))
expect(pmNodeToJSON(node)).toEqual(json)
})
})
describe('jsonToPmNode', () => {
it('converts json to ProseMirrorNode', () => {
const markup = '{"type":"paragraph","content":[{"type":"text","text":"Hello, world!"}]}'
const node = markupToPmNode(markup)
expect(node.type.name).toEqual('paragraph')
expect(node.content.childCount).toEqual(1)
expect(node.content.child(0).type.name).toEqual('text')
expect(node.content.child(0).text).toEqual('Hello, world!')
})
})
describe('htmlToMarkup', () => {
it('converts HTML to Markup', () => {
const html = '<p>hello</p>'
const expectedMarkup = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
expect(htmlToMarkup(html)).toEqual(expectedMarkup)
})
})
describe('markupToHTML', () => {
it('converts markup to HTML', () => {
const markup = '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}'
const expectedHtml = '<p>hello</p>'
expect(markupToHTML(markup)).toEqual(expectedHtml)
})
})
describe('htmlToJSON', () => {
it('converts HTML to JSON', () => {
const html = '<p>hello</p>'
const json = nodeDoc(nodeParagraph(nodeText('hello')))
expect(htmlToJSON(html)).toEqual(json)
})
})
describe('jsonToHTML', () => {
it('converts JSON to HTML', () => {
const json = nodeDoc(nodeParagraph(nodeText('hello')))
const html = '<p>hello</p>'
expect(jsonToHTML(json)).toEqual(html)
})
})
describe('pmNodeToHTML', () => {
it('converts ProseMirrorNode to HTML', () => {
const schema = getSchema(extensions)
const node = schema.node('paragraph', {}, [schema.text('hello')])
expect(pmNodeToHTML(node)).toEqual('<p>hello</p>')
})
})
describe('htmlToPmNode', () => {
it('converts html to ProseMirrorNode', () => {
const node = htmlToPmNode('<p>hello</p>')
expect(node.type.name).toEqual('doc')
expect(node.content.childCount).toEqual(1)
expect(node.content.child(0).type.name).toEqual('paragraph')
expect(node.content.child(0).childCount).toEqual(1)
expect(node.content.child(0).child(0).type.name).toEqual('text')
expect(node.content.child(0).child(0).text).toEqual('hello')
})
})
describe('jsonToText', () => {
it('returns text for text node', () => {
const node: MarkupNode = {
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.text,
text: 'Hello, world!'
}
]
}
expect(jsonToText(node)).toEqual('Hello, world!')
})
it('returns concatenated text for block node with multiple children', () => {
const node: MarkupNode = {
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.text,
text: 'Hello '
},
{
type: MarkupNodeType.text,
text: 'world!'
}
]
}
expect(jsonToText(node)).toEqual('Hello world!')
})
it('returns text for node with link', () => {
const node: MarkupNode = {
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.text,
text: 'Hello! Check out '
},
{
type: MarkupNodeType.text,
text: 'this page',
marks: [
{
type: MarkupMarkType.link,
attrs: {
href: 'http://example.com/'
}
}
]
},
{
type: MarkupNodeType.text,
text: '!'
}
]
}
expect(jsonToText(node)).toEqual('Hello! Check out this page!')
})
it('returns empty string for block node with no children', () => {
const node: MarkupNode = {
type: MarkupNodeType.paragraph,
content: []
}
expect(jsonToText(node)).toEqual('')
})
it('returns error for text node with no text', () => {
const node: MarkupNode = {
type: MarkupNodeType.text,
text: ''
}
expect(() => jsonToText(node)).toThrow('Empty text nodes are not allowed')
})
it('returns error for block node with empty children', () => {
const node: MarkupNode = {
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.text,
text: ''
},
{
type: MarkupNodeType.text,
text: ''
}
]
}
expect(() => jsonToText(node)).toThrow('Empty text nodes are not allowed')
})
})

View File

@ -0,0 +1,81 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { MarkupMark, MarkupMarkType, MarkupNode, MarkupNodeType } from './model'
// Nodes
export function nodeDoc (...content: MarkupNode[]): MarkupNode {
return node(MarkupNodeType.doc, ...content)
}
export function nodeParagraph (...content: MarkupNode[]): MarkupNode {
return node(MarkupNodeType.paragraph, ...content)
}
export function nodeText (text: string): MarkupNode {
return { type: MarkupNodeType.text, text }
}
export function nodeImage (attrs: { src: string, alt?: string, width?: number, height?: number }): MarkupNode {
return { type: MarkupNodeType.image, attrs }
}
export function nodeReference (attrs: { id: string, label: string, objectclass: string }): MarkupNode {
return { type: MarkupNodeType.reference, attrs }
}
// Marks
export function markBold (node: MarkupNode): MarkupNode {
return withMarks(node, mark(MarkupMarkType.bold))
}
export function markCode (node: MarkupNode): MarkupNode {
return withMarks(node, mark(MarkupMarkType.code))
}
export function markItalic (node: MarkupNode): MarkupNode {
return withMarks(node, mark(MarkupMarkType.em))
}
export function markStrike (node: MarkupNode): MarkupNode {
return withMarks(node, mark(MarkupMarkType.strike))
}
export function markUnderline (node: MarkupNode): MarkupNode {
return withMarks(node, mark(MarkupMarkType.underline))
}
export function markLink (attrs: { href: string, title: string }, node: MarkupNode): MarkupNode {
return withMarks(node, mark(MarkupMarkType.link, attrs))
}
// Utility
function node (type: MarkupNodeType, ...content: MarkupNode[]): MarkupNode {
return { type, content }
}
function mark (type: MarkupMarkType, attrs?: Record<string, any>): MarkupMark {
return { type, attrs: attrs ?? {} }
}
function withMarks (node: MarkupNode, ...marks: MarkupMark[]): MarkupNode {
const current = node.marks ?? []
current.push(...marks)
return { ...node, marks: current }
}

View File

@ -0,0 +1,82 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
/** @public */
export enum MarkupNodeType {
doc = 'doc',
paragraph = 'paragraph',
blockquote = 'blockquote',
horizontal_rule = 'horizontalRule',
heading = 'heading',
code_block = 'codeBlock',
text = 'text',
image = 'image',
reference = 'reference',
hard_break = 'hardBreak',
ordered_list = 'orderedList',
bullet_list = 'bulletList',
list_item = 'listItem',
taskList = 'taskList',
taskItem = 'taskItem',
sub = 'sub',
table = 'table',
table_row = 'tableRow',
table_cell = 'tableCell',
table_header = 'tableHeader'
}
/** @public */
export enum MarkupMarkType {
link = 'link',
em = 'italic',
bold = 'bold',
code = 'code',
strike = 'strike',
underline = 'underline'
}
/** @public */
export interface MarkupMark {
type: MarkupMarkType
attrs: Record<string, any> // A map of attributes
}
/** @public */
export interface MarkupNode {
type: MarkupNodeType
content?: MarkupNode[] // A list of child nodes
marks?: MarkupMark[]
attrs?: Record<string, string | number>
text?: string
}
/** @public */
export function emptyMarkupNode (): MarkupNode {
return {
type: MarkupNodeType.doc,
content: [{ type: MarkupNodeType.paragraph }]
}
}
/** @public */
export interface LinkMark extends MarkupMark {
href: string
title: string
}
/** @public */
export interface ReferenceMark extends MarkupMark {
attrs: { id: string, label: string, objectclass: string }
}

View File

@ -0,0 +1,46 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { MarkupMark, MarkupNode } from './model'
export function traverseNode (node: MarkupNode, f: (el: MarkupNode) => boolean | undefined): void {
const result = f(node)
if (result !== false) {
node.content?.forEach((p) => {
traverseNode(p, f)
})
}
}
export function traverseNodeMarks (node: MarkupNode, f: (el: MarkupMark) => void): void {
node.marks?.forEach((p) => {
f(p)
})
}
export function traverseNodeContent (node: MarkupNode, f: (el: MarkupNode) => void): void {
node.content?.forEach((p) => {
f(p)
})
}
export function traverseAllMarks (node: MarkupNode, f: (el: MarkupNode, mark: MarkupMark) => void): void {
traverseNode(node, (node) => {
traverseNodeMarks(node, (mark) => {
f(node, mark)
})
return true
})
}

View File

@ -0,0 +1,199 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Markup } from '@hcengineering/core'
import { Editor, Extensions, getSchema } from '@tiptap/core'
import { generateHTML, generateJSON } from '@tiptap/html'
import { Node as ProseMirrorNode, Schema } from '@tiptap/pm/model'
import { defaultExtensions } from '../extensions'
import { MarkupNode, emptyMarkupNode } from './model'
import { nodeDoc, nodeParagraph, nodeText } from './dsl'
/** @public */
export const EmptyMarkup: Markup = jsonToMarkup(emptyMarkupNode())
/** @public */
export function getMarkup (editor: Editor): Markup {
return jsonToMarkup(editor.getJSON() as MarkupNode)
}
/** @public */
export function isEmptyMarkup (markup: Markup | undefined): boolean {
if (markup === undefined || markup === null || markup === '') {
return true
}
const node = markupToPmNode(markup)
return node.textContent.trim() === ''
}
/** @public */
export function areEqualMarkups (markup1: Markup, markup2: Markup): boolean {
if (markup1 === markup2) {
return true
}
const node1 = markupToPmNode(markup1)
const node2 = markupToPmNode(markup2)
return node1.textContent.trim() === node2.textContent.trim()
}
// Markup
/** @public */
export function pmNodeToMarkup (node: ProseMirrorNode): Markup {
return jsonToMarkup(pmNodeToJSON(node))
}
/** @public */
export function markupToPmNode (markup: Markup, schema?: Schema, extensions?: Extensions): ProseMirrorNode {
const json = markupToJSON(markup)
return jsonToPmNode(json, schema, extensions)
}
// JSON
/** @public */
export function jsonToMarkup (json: MarkupNode): Markup {
return JSON.stringify(json)
}
/** @public */
export function markupToJSON (markup: Markup): MarkupNode {
if (markup == null || markup === '') {
return emptyMarkupNode()
}
try {
// Ideally Markup should contain only serialized JSON
// But there seem to be some cases when it contains HTML or plain text
// So we need to handle those cases and produce valid MarkupNode
if (markup.startsWith('{')) {
return JSON.parse(markup) as MarkupNode
} else if (markup.startsWith('<')) {
return htmlToJSON(markup, defaultExtensions)
} else {
return nodeDoc(nodeParagraph(nodeText(markup)))
}
} catch (error) {
return emptyMarkupNode()
}
}
/** @public */
export function jsonToPmNode (json: MarkupNode, schema?: Schema, extensions?: Extensions): ProseMirrorNode {
schema ??= getSchema(extensions ?? defaultExtensions)
return ProseMirrorNode.fromJSON(schema, json)
}
/** @public */
export function pmNodeToJSON (node: ProseMirrorNode): MarkupNode {
return node.toJSON()
}
/** @public */
export function jsonToText (node: MarkupNode, schema?: Schema, extensions?: Extensions): string {
const pmNode = jsonToPmNode(node, schema, extensions)
return pmNode.textBetween(0, pmNode.content.size, '\n', '')
}
/** @public */
export function pmNodeToText (node: ProseMirrorNode): string {
return jsonToText(node.toJSON())
}
// HTML
/** @public */
export function htmlToMarkup (html: string, extensions?: Extensions): Markup {
const json = htmlToJSON(html, extensions)
return jsonToMarkup(json)
}
/** @public */
export function markupToHTML (markup: Markup, extensions?: Extensions): string {
const json = markupToJSON(markup)
return jsonToHTML(json, extensions)
}
/** @public */
export function htmlToJSON (html: string, extensions?: Extensions): MarkupNode {
extensions = extensions ?? defaultExtensions
return generateJSON(html, extensions) as MarkupNode
}
/** @public */
export function jsonToHTML (json: MarkupNode, extensions?: Extensions): string {
extensions = extensions ?? defaultExtensions
return generateHTML(json, extensions)
}
/** @public */
export function htmlToPmNode (html: string, schema?: Schema, extensions?: Extensions): ProseMirrorNode {
schema ??= getSchema(extensions ?? defaultExtensions)
const json = htmlToJSON(html, extensions)
return ProseMirrorNode.fromJSON(schema, json)
}
/** @public */
export function pmNodeToHTML (node: ProseMirrorNode, extensions?: Extensions): string {
extensions ??= defaultExtensions
return generateHTML(node.toJSON(), extensions)
}
// UTILS
const ELLIPSIS_CHAR = '…'
const WHITESPACE = ' '
/** @public */
export function stripTags (markup: Markup, textLimit = 0, extensions: Extensions | undefined = undefined): string {
const schema = getSchema(extensions ?? defaultExtensions)
const parsed = markupToPmNode(markup, schema)
const textParts: string[] = []
let charCount = 0
let isHardStop = false
parsed.descendants((node, _pos, parent): boolean => {
if (isHardStop) {
return false
}
if (node.type.isText) {
const text = node.text ?? ''
if (textLimit > 0 && charCount + text.length > textLimit) {
const toAddCount = textLimit - charCount
const textPart = text.substring(0, toAddCount)
textParts.push(textPart)
textParts.push(ELLIPSIS_CHAR)
isHardStop = true
} else {
textParts.push(text)
charCount += text.length
}
return false
} else if (node.type.isBlock) {
if (textParts.length > 0 && textParts[textParts.length - 1] !== WHITESPACE) {
textParts.push(WHITESPACE)
charCount++
}
}
return true
})
const result = textParts.join('')
return result
}

View File

@ -14,21 +14,27 @@
//
import { Extensions, getSchema } from '@tiptap/core'
import { Node } from 'prosemirror-model'
import { Node, Schema } from 'prosemirror-model'
import { yDocToProsemirrorJSON } from 'y-prosemirror'
import { Doc, applyUpdate } from 'yjs'
import { defaultExtensions } from './extensions'
/**
* Get ProseMirror node from Y.Doc content
*
* @public
*/
export function yDocContentToNode (extensions: Extensions, content: ArrayBuffer, field?: string): Node {
export function yDocContentToNode (
content: ArrayBuffer,
field?: string,
schema?: Schema,
extensions?: Extensions
): Node {
const ydoc = new Doc()
const uint8arr = new Uint8Array(content)
applyUpdate(ydoc, uint8arr)
return yDocToNode(extensions, ydoc, field)
return yDocToNode(ydoc, field, schema, extensions)
}
/**
@ -36,8 +42,8 @@ export function yDocContentToNode (extensions: Extensions, content: ArrayBuffer,
*
* @public
*/
export function yDocToNode (extensions: Extensions, ydoc: Doc, field?: string): Node {
const schema = getSchema(extensions)
export function yDocToNode (ydoc: Doc, field?: string, schema?: Schema, extensions?: Extensions): Node {
schema ??= getSchema(extensions ?? defaultExtensions)
try {
const body = yDocToProsemirrorJSON(ydoc, field)
@ -53,8 +59,8 @@ export function yDocToNode (extensions: Extensions, ydoc: Doc, field?: string):
*
* @public
*/
export function yDocContentToNodes (extensions: Extensions, content: ArrayBuffer): Node[] {
const schema = getSchema(extensions)
export function yDocContentToNodes (content: ArrayBuffer, schema?: Schema, extensions?: Extensions): Node[] {
schema ??= getSchema(extensions ?? defaultExtensions)
const nodes: Node[] = []

View File

@ -104,16 +104,6 @@
ol ol ol ol ol ol { list-style: lower-roman; }
ol ol ol ol ol ol ol { list-style: decimal; }
blockquote {
margin-inline: 1px 0;
padding-left: 1.5em;
padding-right: 1.5em;
font-style: italic;
position: relative;
border-left: 3px solid var(--theme-text-primary-color);
}
/* Placeholder (at the top) */
p.is-editor-empty:first-child::before {
content: attr(data-placeholder);

View File

@ -300,6 +300,16 @@ table.proseTable {
}
}
.proseBlockQuote {
margin-inline: 1px 0;
padding-left: 1.5em;
padding-right: 1.5em;
font-style: italic;
position: relative;
border-left: 3px solid var(--theme-text-primary-color);
}
.proseCode {
display: inline;
margin: 0;

View File

@ -23,6 +23,7 @@
import { floorFractionDigits } from '../utils'
import { themeStore } from '@hcengineering/theme'
export let id: string | undefined = undefined
export let label: IntlString | undefined = undefined
export let maxWidth: string = '100%'
export let value: string | number | undefined = undefined
@ -106,6 +107,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
{id}
class="antiEditBox"
class:flex-grow={fullSize}
class:w-full={focusable || fullSize}

View File

@ -25,7 +25,7 @@
import { Action } from '@hcengineering/ui'
import { Ref } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { MessageViewer } from '@hcengineering/presentation'
import { HTMLViewer } from '@hcengineering/presentation'
import ActivityMessageTemplate from '../activity-message/ActivityMessageTemplate.svelte'
import ActivityMessageHeader from '../activity-message/ActivityMessageHeader.svelte'
@ -99,7 +99,7 @@
<svelte:fragment slot="content">
<div class="flex-row-center">
<div class="customContent">
<MessageViewer message={content} />
<HTMLViewer value={content} />
</div>
</div>
</svelte:fragment>

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, tick } from 'svelte'
import { Attachment } from '@hcengineering/attachment'
import { Account, Class, Doc, generateId, IdMap, Ref, Space, toIdMap } from '@hcengineering/core'
import { Account, Class, Doc, generateId, IdMap, Markup, Ref, Space, toIdMap } from '@hcengineering/core'
import { IntlString, setPlatformStatus, unknownError, Asset } from '@hcengineering/platform'
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation'
import textEditor, { AttachIcon, type RefAction, ReferenceInput } from '@hcengineering/text-editor'
@ -27,7 +27,7 @@
export let objectId: Ref<Doc>
export let space: Ref<Space>
export let _class: Ref<Class<Doc>>
export let content: string = ''
export let content: Markup = ''
export let iconSend: Asset | AnySvelteComponent | undefined = undefined
export let labelSend: IntlString | undefined = undefined
export let showSend = true

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'
import { Attachment } from '@hcengineering/attachment'
import { Account, Class, Doc, generateId, Ref, Space, toIdMap } from '@hcengineering/core'
import { Account, Class, Doc, generateId, Markup, Ref, Space, toIdMap } from '@hcengineering/core'
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'
@ -28,7 +28,7 @@
export let objectId: Ref<Doc> | undefined = undefined
export let space: Ref<Space> | undefined = undefined
export let _class: Ref<Class<Doc>> | undefined = undefined
export let content: string = ''
export let content: Markup = ''
export let placeholder: IntlString | undefined = undefined
export let alwaysEdit = false
export let showButtons = false

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { Calendar, RecurringRule, Visibility, generateEventId } from '@hcengineering/calendar'
import { Person, PersonAccount } from '@hcengineering/contact'
import { Class, Doc, Ref, getCurrentAccount } from '@hcengineering/core'
import { Class, Doc, Markup, Ref, getCurrentAccount } from '@hcengineering/core'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor'
import {
@ -62,7 +62,7 @@
let reminders = [30 * 60 * 1000]
let description: string = ''
let description: Markup = ''
let visibility: Visibility = 'private'
const me = getCurrentAccount()
let space: Ref<Calendar> = `${me._id}_calendar` as Ref<Calendar>

View File

@ -21,6 +21,7 @@
import chunter, { ChatMessage, ThreadMessage } from '@hcengineering/chunter'
import { PersonAccount } from '@hcengineering/contact'
import activity, { ActivityMessage } from '@hcengineering/activity'
import { EmptyMarkup } from '@hcengineering/text-editor'
export let object: Doc
export let chatMessage: ChatMessage | undefined = undefined
@ -47,8 +48,8 @@
const draftController = new DraftController<MessageDraft>(draftKey)
const currentDraft = shouldSaveDraft ? $draftsStore[draftKey] : undefined
const emptyMessage = {
message: '<p></p>',
const emptyMessage: Pick<MessageDraft, 'message' | 'attachments'> = {
message: EmptyMarkup,
attachments: 0
}

View File

@ -15,7 +15,7 @@
import { ActivityMessage, ActivityMessageViewlet } from '@hcengineering/activity'
import type { Person } from '@hcengineering/contact'
import type { Account, AttachedDoc, Class, Doc, Mixin, Ref, Space, Timestamp } from '@hcengineering/core'
import type { Account, AttachedDoc, Class, Doc, Markup, Mixin, Ref, Space, Timestamp } from '@hcengineering/core'
import { NotificationType } from '@hcengineering/notification'
import type { Asset, Plugin, Resource } from '@hcengineering/platform'
import { IntlString, plugin } from '@hcengineering/platform'
@ -47,7 +47,7 @@ export interface DirectMessage extends ChunterSpace {}
* @deprecated use ChatMessage instead
*/
export interface ChunterMessage extends AttachedDoc {
content: string
content: Markup
attachments?: number
createBy: Ref<Account>
editedOn?: Timestamp
@ -90,7 +90,7 @@ export interface ObjectChatPanel extends Class<Doc> {
* @public
*/
export interface ChatMessage extends ActivityMessage {
message: string
message: Markup
attachments?: number
editedOn?: Timestamp
}

View File

@ -61,7 +61,7 @@
"@hcengineering/document": "^0.6.0",
"@hcengineering/time": "^0.6.0",
"@hcengineering/rank": "^0.6.0",
"@tiptap/core": "^2.1.12",
"@tiptap/core": "^2.2.4",
"slugify": "^1.6.6",
"fast-equals": "^2.0.3"
}

View File

@ -30,7 +30,6 @@
import document, { Teamspace } from '@hcengineering/document'
import { Asset } from '@hcengineering/platform'
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor'
import {
Button,
EditBox,
@ -274,6 +273,7 @@
</div>
<div class="padding">
<EditBox
id="teamspace-title"
bind:value={name}
placeholder={documentRes.string.TeamspaceTitlePlaceholder}
kind={'large-style'}
@ -291,11 +291,10 @@
<div class="antiGrid-row__header topAlign">
<Label label={documentRes.string.Description} />
</div>
<div class="padding clear-mins">
<StyledTextBox
alwaysEdit
showButtons={false}
bind:content={description}
<div class="padding">
<EditBox
id="teamspace-description"
bind:value={description}
placeholder={documentRes.string.TeamspaceDescriptionPlaceholder}
/>
</div>

View File

@ -43,6 +43,7 @@
"@hcengineering/gmail": "^0.6.15",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/presentation": "^0.6.2",
"@hcengineering/text": "^0.6.1",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/contact": "^0.6.20",
"@hcengineering/setting": "^0.6.11",

View File

@ -17,13 +17,14 @@
import attachmentP, { Attachment } from '@hcengineering/attachment'
import { AttachmentPresenter } from '@hcengineering/attachment-resources'
import contact, { Channel, Contact, getName } from '@hcengineering/contact'
import { Data, generateId } from '@hcengineering/core'
import { Data, Markup, generateId } from '@hcengineering/core'
import { NewMessage, SharedMessage } from '@hcengineering/gmail'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Integration } from '@hcengineering/setting'
import templates, { TemplateDataProvider } from '@hcengineering/templates'
import { EmptyMarkup, htmlToMarkup, isEmptyMarkup } from '@hcengineering/text'
import { StyledTextEditor } from '@hcengineering/text-editor'
import { Button, EditBox, IconArrowLeft, IconAttachment, Label, Scroller } from '@hcengineering/ui'
import { createEventDispatcher, onDestroy } from 'svelte'
@ -43,9 +44,10 @@
let copy: string = ''
const obj: Data<NewMessage> = {
let content: Markup = EmptyMarkup
const obj: Omit<Data<NewMessage>, 'content'> = {
subject: currentMessage ? 'RE: ' + currentMessage.subject : '',
content: '',
to: channel.value,
replyTo: currentMessage?.messageId,
status: 'new'
@ -53,7 +55,7 @@
let templateProvider: TemplateDataProvider | undefined
getResource(templates.function.GetTemplateDataProvider).then((p) => {
void getResource(templates.function.GetTemplateDataProvider).then((p) => {
templateProvider = p()
})
@ -61,14 +63,15 @@
templateProvider?.destroy()
})
$: templateProvider && templateProvider.set(contact.class.Contact, object)
$: templateProvider !== undefined && templateProvider.set(contact.class.Contact, object)
async function sendMsg () {
async function sendMsg (): Promise<void> {
await client.createDoc(
plugin.class.NewMessage,
plugin.space.Gmail,
{
...obj,
content: htmlToMarkup(content),
attachments: attachments.length,
from: selectedIntegration.createdBy,
copy: copy
@ -86,7 +89,7 @@
const dispatch = createEventDispatcher()
let inputFile: HTMLInputElement
function fileSelected () {
function fileSelected (): void {
progress = true
const list = inputFile.files
if (list === null || list.length === 0) return
@ -98,7 +101,7 @@
progress = false
}
function fileDrop (e: DragEvent) {
function fileDrop (e: DragEvent): void {
e.preventDefault()
e.stopPropagation()
progress = true
@ -207,7 +210,7 @@
<Button
label={plugin.string.Send}
kind={'primary'}
disabled={progress || obj.content === '' || obj.content === '<p></p>'}
disabled={progress || isEmptyMarkup(content)}
on:click={sendMsg}
/>
</div>
@ -239,7 +242,7 @@
<EditBox label={plugin.string.Copy} bind:value={copy} placeholder={plugin.string.CopyPlaceholder} />
</div>
<div class="input clear-mins">
<StyledTextEditor full bind:content={obj.content} maxHeight={'max'} on:template={onTemplate} />
<StyledTextEditor full bind:content maxHeight={'max'} on:template={onTemplate} />
</div>
</Scroller>
<div class="antiVSpacer x2" />

View File

@ -17,13 +17,13 @@
import attachmentP, { Attachment } from '@hcengineering/attachment'
import { AttachmentPresenter } from '@hcengineering/attachment-resources'
import contact, { Channel, Contact, getName as getContactName } from '@hcengineering/contact'
import { generateId, getCurrentAccount, Ref, toIdMap } from '@hcengineering/core'
import { generateId, getCurrentAccount, Markup, Ref, toIdMap } from '@hcengineering/core'
import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import setting, { Integration } from '@hcengineering/setting'
import templates, { TemplateDataProvider } from '@hcengineering/templates'
import { StyledTextEditor } from '@hcengineering/text-editor'
import { StyledTextEditor, isEmptyMarkup } from '@hcengineering/text-editor'
import {
Button,
EditBox,
@ -38,6 +38,7 @@
import plugin from '../plugin'
import Connect from './Connect.svelte'
import IntegrationSelector from './IntegrationSelector.svelte'
import { markupToHTML } from '@hcengineering/text'
export let value: Contact[] | Contact
const contacts = Array.isArray(value) ? value : [value]
@ -69,18 +70,19 @@
const attachmentParentId = generateId()
let subject: string = ''
let content: string = ''
let content: Markup = ''
let copy: string = ''
let saved = false
async function sendMsg () {
async function sendMsg (): Promise<void> {
const templateProvider = (await getResource(templates.function.GetTemplateDataProvider))()
if (templateProvider === undefined || selectedIntegration === undefined) return
for (const channel of channels) {
const target = contacts.find((p) => p._id === channel.attachedTo)
if (target === undefined) continue
templateProvider.set(contact.class.Contact, target)
const message = await templateProvider.fillTemplate(content)
const htmlContent = markupToHTML(content)
const message = await templateProvider.fillTemplate(htmlContent)
const id = await client.createDoc(plugin.class.NewMessage, plugin.space.Gmail, {
subject,
content: message,
@ -296,7 +298,7 @@
label={plugin.string.Send}
size={'small'}
kind={'primary'}
disabled={channels.length === 0 || content === '' || content === '<p></p>'}
disabled={channels.length === 0 || isEmptyMarkup(content)}
on:click={sendMsg}
/>
{/if}

View File

@ -16,7 +16,7 @@
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import calendar from '@hcengineering/calendar'
import { Employee } from '@hcengineering/contact'
import core, { DocumentQuery, generateId, Ref } from '@hcengineering/core'
import core, { DocumentQuery, generateId, Markup, Ref } from '@hcengineering/core'
import { Request, RequestType, Staff, toTzDate } from '@hcengineering/hr'
import { translate } from '@hcengineering/platform'
import { Card, createQuery, getClient } from '@hcengineering/presentation'
@ -39,7 +39,7 @@
export let docQuery: DocumentQuery<Employee> | undefined
export let employeeRequests: Map<Ref<Staff>, Request[]>
let description: string = ''
let description: Markup = ''
let employee: Ref<Employee> = staff._id
const objectId: Ref<Request> = generateId()

View File

@ -15,7 +15,7 @@
//
import type { Contact } from '@hcengineering/contact'
import type { Attribute, Class, Doc, Ref, Status, Timestamp } from '@hcengineering/core'
import type { Attribute, Class, Doc, Markup, Ref, Status, Timestamp } from '@hcengineering/core'
import { Mixin } from '@hcengineering/core'
import type { Asset, IntlString, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
@ -25,7 +25,7 @@ import type { Project, ProjectType, ProjectTypeDescriptor, Task, TaskType } from
* @public
*/
export interface Funnel extends Project {
fullDescription?: string
fullDescription?: Markup
attachments?: number
}

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc } from '@hcengineering/core'
import { Doc, Markup } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { CommonInboxNotification } from '@hcengineering/notification'
@ -23,11 +23,11 @@
const client = getClient()
let content = ''
let content: Markup = ''
$: void updateContent(value.message, value.messageHtml)
async function updateContent (message?: IntlString, messageHtml?: string): Promise<void> {
async function updateContent (message?: IntlString, messageHtml?: Markup): Promise<void> {
if (messageHtml !== undefined) {
content = messageHtml
} else if (message !== undefined) {

View File

@ -21,6 +21,7 @@ import {
Doc,
DocumentQuery,
IdMap,
Markup,
Mixin,
Ref,
Space,
@ -244,7 +245,7 @@ export interface CommonInboxNotification extends InboxNotification {
headerObjectId?: Ref<Doc>
headerObjectClass?: Ref<Class<Doc>>
message?: IntlString
messageHtml?: string
messageHtml?: Markup
props?: Record<string, any>
icon?: Asset
iconProps?: Record<string, any>

View File

@ -5,6 +5,7 @@
Card,
createQuery,
getClient,
HTMLViewer,
IndexedDocumentCompare,
MessageViewer,
SpaceSelect
@ -188,7 +189,7 @@
{vacancy.description}
{/if}
{#if vacancyState?.fullSummary}
<MessageViewer message={vacancyState?.fullSummary.split('\n').join('<br/>')} />
<HTMLViewer value={vacancyState?.fullSummary.split('\n').join('<br/>')} />
{/if}
</div>
</Scroller>

View File

@ -16,7 +16,17 @@
import calendar from '@hcengineering/calendar'
import type { Contact, PersonAccount, Organization, Person } from '@hcengineering/contact'
import contact from '@hcengineering/contact'
import { Account, Class, Client, DateRangeMode, Doc, generateId, getCurrentAccount, Ref } from '@hcengineering/core'
import {
Account,
Class,
Client,
DateRangeMode,
Doc,
generateId,
getCurrentAccount,
Markup,
Ref
} from '@hcengineering/core'
import { getResource, OK, Resource, Severity, Status } from '@hcengineering/platform'
import { Card, getClient } from '@hcengineering/presentation'
import { UserBox, UserBoxList } from '@hcengineering/contact-resources'
@ -45,7 +55,7 @@
let status: Status = OK
let title: string = ''
let description: string = ''
let description: Markup = ''
let startDate: number = initDate.getTime()
let dueDate: number = initDate.getTime() + 30 * 60 * 1000
let location: string = ''

View File

@ -15,7 +15,7 @@
import { Event } from '@hcengineering/calendar'
import type { Channel, Organization, Person } from '@hcengineering/contact'
import type { AttachedData, AttachedDoc, Ref, Space, Status, Timestamp } from '@hcengineering/core'
import type { AttachedData, AttachedDoc, Markup, Ref, Space, Status, Timestamp } from '@hcengineering/core'
import { TagReference } from '@hcengineering/tags'
import type { Project, Task } from '@hcengineering/task'
@ -102,6 +102,6 @@ export interface Opinion extends AttachedDoc {
attachedTo: Ref<Review>
comments?: number
attachments?: number
description: string
description: Markup
value: string
}

View File

@ -16,10 +16,10 @@
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
import chunter, { ChatMessage } from '@hcengineering/chunter'
import { PersonAccount } from '@hcengineering/contact'
import { AttachedData, getCurrentAccount, Ref } from '@hcengineering/core'
import { AttachedData, getCurrentAccount, Markup, Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Request, RequestStatus } from '@hcengineering/request'
import type { RefAction } from '@hcengineering/text-editor'
import { type RefAction, isEmptyMarkup } from '@hcengineering/text-editor'
import { Button } from '@hcengineering/ui'
import request from '../plugin'
@ -53,7 +53,7 @@
})
}
let message: string = ''
let message: Markup = ''
let attachments: number | undefined = 0
async function onUpdate (event: CustomEvent<AttachedData<ChatMessage>>) {
@ -90,7 +90,7 @@
}
function commentIsEmpty (message: string, attachments: number | undefined): boolean {
return (message === '<p></p>' || message.trim().length === 0) && !((attachments ?? 0) > 0)
return isEmptyMarkup(message) && !((attachments ?? 0) > 0)
}
let refInput: AttachmentRefInput

View File

@ -50,6 +50,7 @@
"@hcengineering/setting": "^0.6.11",
"@hcengineering/telegram": "^0.6.14",
"@hcengineering/templates": "^0.6.7",
"@hcengineering/text": "^0.6.1",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/view-resources": "^0.6.0",

View File

@ -25,6 +25,7 @@
import setting, { Integration } from '@hcengineering/setting'
import type { NewTelegramMessage, SharedTelegramMessage, TelegramMessage } from '@hcengineering/telegram'
import templates, { TemplateDataProvider } from '@hcengineering/templates'
import { markupToHTML } from '@hcengineering/text'
import {
Button,
Icon,
@ -121,7 +122,7 @@
channel._class,
'newMessages',
{
content: message,
content: markupToHTML(message),
status: 'new',
attachments
},

View File

@ -18,7 +18,7 @@
import { AttachmentList } from '@hcengineering/attachment-resources'
import { formatName } from '@hcengineering/contact'
import { WithLookup } from '@hcengineering/core'
import { MessageViewer } from '@hcengineering/presentation'
import { HTMLViewer } from '@hcengineering/presentation'
import type { SharedTelegramMessage } from '@hcengineering/telegram'
import { CheckBox, getPlatformColorForText, themeStore } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
@ -34,6 +34,7 @@
<div class="message-row-bg" class:selectable class:selected-row={selected} data-type={message.incoming ? 'in' : 'out'}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="message-row"
class:selectable
@ -56,7 +57,7 @@
<AttachmentList {attachments} />
{/if}
<div class="flex">
<div class="caption-color mr-4"><MessageViewer message={message.content} /></div>
<div class="caption-color mr-4"><HTMLViewer value={message.content} /></div>
<div class="time">
{new Date(message.sendOn).toLocaleString('default', { hour: 'numeric', minute: 'numeric' })}
</div>

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { createQuery, MessageViewer } from '@hcengineering/presentation'
import { createQuery, HTMLViewer } from '@hcengineering/presentation'
import { TelegramMessage } from '@hcengineering/telegram'
import { Ref } from '@hcengineering/core'
@ -38,7 +38,7 @@
{#if doc}
<div class="content lines-limit-2">
<MessageViewer message={doc.content} />
<HTMLViewer value={doc.content} />
</div>
{/if}

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { createQuery, getClient, MessageViewer } from '@hcengineering/presentation'
import { createQuery, getClient, HTMLViewer } from '@hcengineering/presentation'
import { TelegramMessage } from '@hcengineering/telegram'
import { Ref } from '@hcengineering/core'
import { buildRemovedDoc, checkIsObjectRemoved } from '@hcengineering/view-resources'
@ -29,7 +29,7 @@
$: value === undefined && _id && loadObject(_id)
async function loadObject (_id: Ref<TelegramMessage>) {
async function loadObject (_id: Ref<TelegramMessage>): Promise<void> {
const isRemoved = await checkIsObjectRemoved(client, _id, telegram.class.Message)
if (isRemoved) {
@ -44,7 +44,7 @@
{#if value}
<div class="content lines-limit-2 overflow-label">
<MessageViewer message={value.content} {preview} />
<HTMLViewer value={value.content} {preview} />
</div>
{/if}

View File

@ -13,13 +13,13 @@
// limitations under the License.
-->
<script lang="ts">
import { MessageViewer } from '@hcengineering/presentation'
import { HTMLViewer } from '@hcengineering/presentation'
import { TelegramMessage } from '@hcengineering/telegram'
export let value: TelegramMessage
</script>
<div class="content lines-limit-2">
<MessageViewer message={value.content} />
<HTMLViewer value={value.content} />
</div>
<style lang="scss">

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import type { Class, Doc, Ref, Space } from '@hcengineering/core'
import type { Class, Doc, Markup, Ref, Space } from '@hcengineering/core'
import type { IntlString, Plugin, Resource } from '@hcengineering/platform'
import { Asset, plugin } from '@hcengineering/platform'
import { AnyComponent } from '@hcengineering/ui'
@ -29,7 +29,7 @@ export interface TemplateCategory extends Space {}
export interface MessageTemplate extends Doc {
space: Ref<TemplateCategory>
title: string
message: string
message: Markup
}
/**

View File

@ -46,6 +46,7 @@
import tags, { TagElement, TagReference } from '@hcengineering/tags'
import { TaskType, makeRank } from '@hcengineering/task'
import { TaskKindSelector } from '@hcengineering/task-resources'
import { EmptyMarkup } from '@hcengineering/text-editor'
import {
Component as ComponentType,
Issue,
@ -227,7 +228,7 @@
assignee: assignee ?? currentProject?.defaultAssignee,
status: status ?? currentProject?.defaultIssueStatus,
parentIssue: parentIssue?._id,
description: '<p></p>',
description: EmptyMarkup,
component: component ?? $activeComponent ?? null,
milestone: milestone ?? $activeMilestone ?? null,
priority: priority ?? IssuePriority.NoPriority,

View File

@ -31,7 +31,6 @@
import presentation, { Card, createQuery, getClient } from '@hcengineering/presentation'
import task, { ProjectType, TaskType } from '@hcengineering/task'
import { taskTypeStore, typeStore } from '@hcengineering/task-resources'
import { StyledTextBox } from '@hcengineering/text-editor'
import { IssueStatus, Project, TimeReportDayType } from '@hcengineering/tracker'
import {
Button,
@ -339,6 +338,7 @@
</div>
<div class="padding">
<EditBox
id="project-title"
bind:value={name}
placeholder={tracker.string.ProjectTitlePlaceholder}
kind={'large-style'}
@ -360,6 +360,7 @@
</div>
<div bind:this={changeIdentityRef} class="padding flex-row-center relative">
<EditBox
id="project-identifier"
bind:value={identifier}
disabled={!isNew}
placeholder={tracker.string.ProjectIdentifierPlaceholder}
@ -379,10 +380,9 @@
<Label label={tracker.string.Description} />
</div>
<div class="padding clear-mins">
<StyledTextBox
alwaysEdit
showButtons={false}
bind:content={description}
<EditBox
id="project-description"
bind:value={description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
/>
</div>

View File

@ -48,6 +48,7 @@
"@hcengineering/view": "^0.6.9",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/task": "^0.6.13",
"@hcengineering/text": "^0.6.1",
"@hcengineering/preference": "^0.6.9",
"@hcengineering/notification": "^0.6.16",
"@hcengineering/presentation": "^0.6.2",

View File

@ -19,10 +19,8 @@
import { getAttribute, getClient, KeyedAttribute, updateAttribute } from '@hcengineering/presentation'
import { FullDescriptionBox } from '@hcengineering/text-editor'
// export let objectId: Ref<Doc>
// export let _class: Ref<Class<Doc>>
// TODO Rename this component to MarkupEditor
export let object: Doc
// export let space: Ref<Space>
export let key: KeyedAttribute
$: description = getAttribute(getClient(), object, key)

View File

@ -14,12 +14,12 @@
// limitations under the License.
-->
<script lang="ts">
import { MessageViewer } from '@hcengineering/presentation'
import { HTMLViewer } from '@hcengineering/presentation'
import { ShowMore } from '@hcengineering/ui'
export let value: string
</script>
<ShowMore>
<MessageViewer message={value} />
<HTMLViewer {value} />
</ShowMore>

View File

@ -1,6 +1,6 @@
<!--
// Copyright © 2023 Anticrm Platform Contributors.
// Copyright © 2023 Hardcore Engineering Inc.
// Copyright © 2023, 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -14,35 +14,78 @@
// limitations under the License.
-->
<script lang="ts">
import { Markup } from '@hcengineering/core'
import { EmptyMarkup, MarkupNode, MarkupNodeType, markupToJSON } from '@hcengineering/text'
import { MarkupDiffViewer } from '@hcengineering/text-editor'
import { ShowMore } from '@hcengineering/ui'
import { deepEqual } from 'fast-equals'
export let value: Markup | undefined
export let prevValue: Markup | undefined = undefined
export let value: string | undefined
export let prevValue: string | undefined = undefined
export let showOnlyDiff: boolean = false
function removeSimilarLines (str1: string | undefined, str2: string | undefined) {
str1 = str1 ?? ''
str2 = str2 ?? ''
const lines1 = str1.split('</p>')
const lines2 = str2.split('</p>')
let result1 = ''
let result2 = ''
for (let i = 0; i < Math.max(lines1.length, lines2.length); i++) {
if (lines1[i] !== lines2[i]) {
if (lines1[i]) result1 += lines1[i] + '</p>'
if (lines2[i]) result2 += lines2[i] + '</p>'
$: content = markupToJSON(value ?? EmptyMarkup)
$: comparedVersion = markupToJSON(prevValue ?? EmptyMarkup)
function cleanup (node1: MarkupNode, node2: MarkupNode): MarkupNode[] {
if (node1.type !== MarkupNodeType.doc || node2.type !== MarkupNodeType.doc) {
return [node1, node2]
}
const content1 = node1.content ?? []
const content2 = node2.content ?? []
const newContent1: MarkupNode[] = []
const newContent2: MarkupNode[] = []
for (let i = 0; i < Math.max(content1.length, content2.length); i++) {
if (!same(content1[i], content2[i])) {
if (content1[i] !== undefined) {
newContent1.push(content1[i])
}
if (content2[i] !== undefined) {
newContent2.push(content2[i])
}
}
}
value = result1
prevValue = result2
return [
{ ...node1, content: newContent1 },
{ ...node2, content: newContent2 }
]
}
$: showOnlyDiff && removeSimilarLines(value, prevValue)
function same (node1: MarkupNode | undefined, node2: MarkupNode | undefined): boolean {
if (node1 === undefined && node2 === undefined) return true
if (node1 === undefined || node2 === undefined) return false
if (
node1.type !== node2.type ||
node1.text !== node2.text ||
!deepEqual(node1.marks ?? [], node2.marks ?? []) ||
!deepEqual(node1.attrs ?? {}, node2.attrs ?? {})
) {
return false
}
const content1 = node1.content ?? []
const content2 = node2.content ?? []
if (content1.length !== content2.length) return false
for (let i = 0; i < content1.length; i++) {
if (!same(content1[i], content2[i])) return false
}
return true
}
$: if (showOnlyDiff) {
;[content, comparedVersion] = cleanup(content, comparedVersion)
}
</script>
<ShowMore>
{#key [value, prevValue]}
<MarkupDiffViewer content={value ?? ''} comparedVersion={prevValue ?? ''} />
<MarkupDiffViewer {content} {comparedVersion} />
{/key}
</ShowMore>

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { MessageViewer } from '@hcengineering/presentation'
import { HTMLViewer } from '@hcengineering/presentation'
import { getPlatformColor, Label as LabelComponent, themeStore } from '@hcengineering/ui'
import view from '../../plugin'
@ -71,7 +71,7 @@
<a class="fs-title mb-1" {href}>#{data.number} {data.title}</a>
{#if data.body}
<div>
<MessageViewer message={data.body} />
<HTMLViewer value={data.body} />
</div>
{/if}
<div class="flex-between">

View File

@ -14,12 +14,13 @@
//
import { Class, Doc, Ref } from '@hcengineering/core'
import { EmptyMarkup, MarkupNodeType, jsonToMarkup } from '@hcengineering/text'
import { getReferencesData } from '../references'
describe('extractBacklinks', () => {
it('should return no references for empty document', () => {
const content = '<p></p>'
const content = EmptyMarkup
const references = getReferencesData(
'srcDocId' as Ref<Doc>,
'srcDocClass' as Ref<Class<Doc>>,
@ -32,8 +33,28 @@ describe('extractBacklinks', () => {
})
it('should parse single backlink', () => {
const content =
'<p>hello <span class="reference" data-type="reference" data-id="id" data-objectclass="contact:class:Person" data-label="Appleseed John">@Appleseed John</span> </p>'
const content = jsonToMarkup({
type: MarkupNodeType.doc,
content: [
{
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.reference,
attrs: {
id: 'id',
objectclass: 'contact:class:Person',
label: 'Appleseed John'
}
},
{
type: MarkupNodeType.text,
text: ' hello'
}
]
}
]
})
const references = getReferencesData(
'srcDocId' as Ref<Doc>,
@ -52,7 +73,7 @@ describe('extractBacklinks', () => {
srcDocId: 'srcDocId',
srcDocClass: 'srcDocClass',
message:
'hello <span data-type="reference" data-id="id" data-objectclass="contact:class:Person" data-label="Appleseed John" class="reference">@Appleseed John</span>',
'{"type":"paragraph","content":[{"type":"reference","attrs":{"id":"id","objectclass":"contact:class:Person","label":"Appleseed John","class":null}},{"type":"text","text":" hello"}]}',
attachedDocId: 'attachedDocId',
attachedDocClass: 'attachedDocClass'
}
@ -60,8 +81,36 @@ describe('extractBacklinks', () => {
})
it('should parse single backlink for multiple references', () => {
const content =
'<p><span class="reference" data-type="reference" data-id="id" data-label="Appleseed John" data-objectclass="contact:class:Person">@Appleseed John</span> <span data-type="reference" class="reference" data-id="id" data-label="Appleseed John" data-objectclass="contact:class:Person">@Appleseed John</span> </p>'
const content = jsonToMarkup({
type: MarkupNodeType.doc,
content: [
{
type: MarkupNodeType.paragraph,
content: [
{
type: MarkupNodeType.reference,
attrs: {
id: 'id',
objectclass: 'contact:class:Person',
label: 'Appleseed John'
}
},
{
type: MarkupNodeType.text,
text: ' '
},
{
type: MarkupNodeType.reference,
attrs: {
id: 'id',
objectclass: 'contact:class:Person',
label: 'Appleseed John'
}
}
]
}
]
})
const references = getReferencesData(
'srcDocId' as Ref<Doc>,

View File

@ -36,7 +36,7 @@ import core, {
Type
} from '@hcengineering/core'
import notification, { MentionInboxNotification } from '@hcengineering/notification'
import { ServerKit, extractReferences, getHTML, parseHTML, yDocContentToNodes } from '@hcengineering/text'
import { extractReferences, markupToPmNode, pmNodeToMarkup, yDocContentToNodes } from '@hcengineering/text'
import { StorageAdapter, TriggerControl } from '@hcengineering/server-core'
import activity, { ActivityMessage, ActivityReference } from '@hcengineering/activity'
import contact, { Person, PersonAccount } from '@hcengineering/contact'
@ -47,18 +47,16 @@ import {
shouldNotifyCommon
} from '@hcengineering/server-notification-resources'
const extensions = [ServerKit]
export function isDocMentioned (doc: Ref<Doc>, content: string | Buffer): boolean {
const references = []
if (content instanceof Buffer) {
const nodes = yDocContentToNodes(extensions, content)
const nodes = yDocContentToNodes(content)
for (const node of nodes) {
references.push(...extractReferences(node))
}
} else {
const doc = parseHTML(content, extensions)
const doc = markupToPmNode(content)
references.push(...extractReferences(doc))
}
@ -338,12 +336,12 @@ export function getReferencesData (
const references = []
if (content instanceof Buffer) {
const nodes = yDocContentToNodes(extensions, content)
const nodes = yDocContentToNodes(content)
for (const node of nodes) {
references.push(...extractReferences(node))
}
} else {
const doc = parseHTML(content, extensions)
const doc = markupToPmNode(content)
references.push(...extractReferences(doc))
}
@ -355,7 +353,7 @@ export function getReferencesData (
collection: 'references',
srcDocId,
srcDocClass,
message: ref.parentNode !== null ? getHTML(ref.parentNode, extensions) : '',
message: ref.parentNode !== null ? pmNodeToMarkup(ref.parentNode) : '',
attachedDocId,
attachedDocClass
})
@ -461,6 +459,7 @@ async function ActivityReferenceCreate (tx: TxCUD<Doc>, control: TriggerControl)
const ctx = TxProcessor.extractTx(tx) as TxCreateDoc<Doc>
if (ctx._class !== core.class.TxCreateDoc) return []
if (control.hierarchy.isDerived(ctx.objectClass, notification.class.InboxNotification)) return []
if (control.hierarchy.isDerived(ctx.objectClass, activity.class.ActivityReference)) return []
control.storageFx(async (adapter) => {
@ -568,8 +567,9 @@ async function ActivityReferenceRemove (tx: Tx, control: TriggerControl): Promis
export async function ReferenceTrigger (tx: TxCUD<Doc>, control: TriggerControl): Promise<Tx[]> {
const result: Tx[] = []
const etx = TxProcessor.extractTx(tx) as TxCreateDoc<Doc>
const etx = TxProcessor.extractTx(tx) as TxCUD<Doc>
if (control.hierarchy.isDerived(etx.objectClass, activity.class.ActivityReference)) return []
if (control.hierarchy.isDerived(etx.objectClass, notification.class.InboxNotification)) return []
if (etx._class === core.class.TxCreateDoc) {
result.push(...(await ActivityReferenceCreate(tx, control)))

View File

@ -41,6 +41,7 @@
"@hcengineering/task": "^0.6.13",
"@hcengineering/tracker": "^0.6.13",
"@hcengineering/server-time": "^0.6.0",
"@hcengineering/text": "^0.6.1",
"@hcengineering/time": "^0.6.0"
}
}

View File

@ -40,6 +40,7 @@ import {
isShouldNotifyTx
} from '@hcengineering/server-notification-resources'
import task, { makeRank } from '@hcengineering/task'
import { jsonToMarkup, nodeDoc, nodeParagraph, nodeText } from '@hcengineering/text'
import tracker, { Issue, IssueStatus, Project, TimeSpendReport } from '@hcengineering/tracker'
import serverTime, { OnToDo, ToDoFactory } from '@hcengineering/server-time'
import time, { ProjectToDo, ToDo, ToDoPriority, TodoAutomationHelper, WorkSlot } from '@hcengineering/time'
@ -196,7 +197,7 @@ export async function OnToDoCreate (tx: TxCUD<Doc>, control: TriggerControl): Pr
headerIcon: time.icon.Planned,
headerObjectId: object._id,
headerObjectClass: object._class,
messageHtml: todo.title
messageHtml: jsonToMarkup(nodeDoc(nodeParagraph(nodeText(todo.title))))
}
res.push(

View File

@ -63,8 +63,8 @@
"@hcengineering/text": "^0.6.1",
"@hocuspocus/server": "^2.9.0",
"@hocuspocus/transformer": "^2.9.0",
"@tiptap/core": "^2.1.12",
"@tiptap/html": "^2.1.12",
"@tiptap/core": "^2.2.4",
"@tiptap/html": "^2.2.4",
"mongodb": "^6.3.0",
"yjs": "^13.5.52",
"y-prosemirror": "^1.2.1",

View File

@ -33,7 +33,7 @@ import { StorageExtension } from './extensions/storage'
import { Controller, getClientFactory } from './platform'
import { RpcErrorResponse, RpcRequest, RpcResponse, methods } from './rpc'
import { PlatformStorageAdapter } from './storage/platform'
import { HtmlTransformer } from './transformers/html'
import { MarkupTransformer } from './transformers/markup'
/**
* @public
@ -83,7 +83,7 @@ export async function start (
const controller = new Controller()
const transformer = new HtmlTransformer(extensions)
const transformer = new MarkupTransformer(extensions)
const hocuspocus = new Hocuspocus({
address: '0.0.0.0',

View File

@ -220,7 +220,7 @@ export class PlatformStorageAdapter implements CollabStorageAdapter {
})
const content = doc !== null && objectAttr in doc ? ((doc as any)[objectAttr] as string) : ''
if (content.startsWith('<') && content.endsWith('>')) {
if (content.startsWith('{') && content.endsWith('}')) {
return await ctx.with('transform', {}, () => {
return transformer.toYdoc(content, objectAttr)
})

View File

@ -0,0 +1,41 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { jsonToMarkup, markupToJSON } from '@hcengineering/text'
import { TiptapTransformer, Transformer } from '@hocuspocus/transformer'
import { Extensions } from '@tiptap/core'
import { Doc } from 'yjs'
export class MarkupTransformer 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 jsonToMarkup(json)
}
toYdoc (document: any, fieldName: string): Doc {
if (typeof document === 'string' && document !== '') {
const json = markupToJSON(document)
return this.transformer.toYdoc(json, fieldName)
}
return new Doc()
}
}

View File

@ -29,7 +29,6 @@
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.1.0",
"typescript": "^5.3.3",
"@types/html-to-text": "^8.1.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
@ -38,9 +37,9 @@
"dependencies": {
"@hcengineering/core": "^0.6.28",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/text": "^0.6.1",
"@hcengineering/query": "^0.6.8",
"fast-equals": "^2.0.3",
"html-to-text": "^9.0.3",
"uuid": "^8.3.2"
}
}

View File

@ -28,6 +28,7 @@ import core, {
type ServerStorage,
type WorkspaceId
} from '@hcengineering/core'
import { jsonToText, markupToJSON } from '@hcengineering/text'
import { type DbAdapter } from '../adapter'
import { updateDocWithPresenter } from '../mapper'
import { type FullTextAdapter, type IndexedDoc } from '../types'
@ -273,8 +274,15 @@ function updateDoc2Elastic (
(attribute.type as ArrOf<any>).of._class === core.class.RefTo)
))
) {
if (!(doc.fulltextSummary ?? '').includes(vv)) {
doc.fulltextSummary = (doc.fulltextSummary ?? '') + vv + '\n'
let vvv = vv
if (
attribute.type._class === core.class.TypeMarkup ||
attribute.type._class === core.class.TypeCollaborativeMarkup
) {
vvv = jsonToText(markupToJSON(vv))
}
if (!(doc.fulltextSummary ?? '').includes(vvv)) {
doc.fulltextSummary = (doc.fulltextSummary ?? '') + vvv + '\n'
continue
}
}

View File

@ -29,7 +29,7 @@ import core, {
type ServerStorage
} from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { convert } from 'html-to-text'
import { jsonToText, markupToJSON } from '@hcengineering/text'
import { type DbAdapter } from '../adapter'
import { type IndexedDoc } from '../types'
import {
@ -285,10 +285,7 @@ export async function extractIndexedValues (
}
if (keyAttr.type._class === core.class.TypeMarkup || keyAttr.type._class === core.class.TypeCollaborativeMarkup) {
sourceContent = convert(sourceContent, {
preserveNewlines: true,
selectors: [{ selector: 'img', format: 'skip' }]
})
sourceContent = jsonToText(markupToJSON(sourceContent))
}
if (!opt.fieldFilter.every((it) => it(keyAttr, sourceContent))) {

View File

@ -1,10 +1,8 @@
import { MeasureContext, WorkspaceId } from '@hcengineering/core'
import { ContentTextAdapter } from '@hcengineering/server-core'
import { ServerKit, getText, yDocContentToNodes } from '@hcengineering/text'
import { pmNodeToText, yDocContentToNodes } from '@hcengineering/text'
import { Readable } from 'stream'
const extensions = [ServerKit]
/**
* @public
*/
@ -38,8 +36,8 @@ export async function createYDocAdapter (
}
if (chunks.length > 0) {
const nodes = yDocContentToNodes(extensions, Buffer.concat(chunks))
return nodes.map(getText).join('\n')
const nodes = yDocContentToNodes(Buffer.concat(chunks))
return nodes.map(pmNodeToText).join('\n')
}
return ''

View File

@ -27,24 +27,22 @@ export class DocumentsPage extends CommonPage {
this.popupCreateDocument = new DocumentCreatePopup(page)
this.popupMoveDocument = new DocumentMovePopup(page)
const newForm = page.locator('form[id="document:string:NewTeamspace"]')
const editForm = page.locator('form[id="document:string:EditTeamspace"]')
this.buttonCreateDocument = page.locator('div[data-float="navigator"] button[id="new-document"]')
this.divTeamspacesParent = page.locator('div#tree-teamspaces').locator('xpath=..')
this.buttonCreateTeamspace = page.locator('div#tree-teamspaces > button')
this.inputModalNewTeamspaceTitle = page.locator(
'form[id="document:string:NewTeamspace"] input[placeholder="New teamspace"]'
)
this.inputModalNewTeamspaceDescription = page.locator('form[id="document:string:NewTeamspace"] div.tiptap')
this.inputModalNewTeamspacePrivate = page.locator(
'form[id="document:string:NewTeamspace"] div.antiGrid label.toggle'
)
this.buttonModalNewTeamspaceCreate = page.locator('form[id="document:string:NewTeamspace"] button[type="submit"]')
this.buttonModalEditTeamspaceTitle = page.locator('form[id="document:string:EditTeamspace"] input[type="text"]')
this.buttonModalEditTeamspaceDescription = page.locator('form[id="document:string:EditTeamspace"] div.tiptap')
this.buttonModalEditTeamspacePrivate = page.locator(
'form[id="document:string:EditTeamspace"] div.antiGrid label.toggle'
)
this.buttonModalEditTeamspaceSave = page.locator('form[id="document:string:EditTeamspace"] button[type="submit"]')
this.buttonModalEditTeamspaceClose = page.locator('form[id="document:string:EditTeamspace"] button#card-close')
this.inputModalNewTeamspaceTitle = newForm.locator('div[id="teamspace-title"] input')
this.inputModalNewTeamspaceDescription = newForm.locator('div[id="teamspace-description"] input')
this.inputModalNewTeamspacePrivate = newForm.locator('div.antiGrid label.toggle')
this.buttonModalNewTeamspaceCreate = newForm.locator('button[type="submit"]')
this.buttonModalEditTeamspaceTitle = editForm.locator('div[id="teamspace-title"] input')
this.buttonModalEditTeamspaceDescription = editForm.locator('div[id="teamspace-description"] input')
this.buttonModalEditTeamspacePrivate = editForm.locator('div.antiGrid label.toggle')
this.buttonModalEditTeamspaceSave = editForm.locator('button[type="submit"]')
this.buttonModalEditTeamspaceClose = editForm.locator('button#card-close')
}
async createNewTeamspace (data: NewTeamspace): Promise<void> {
@ -117,7 +115,7 @@ export class DocumentsPage extends CommonPage {
async checkTeamspace (data: NewTeamspace): Promise<void> {
await expect(this.buttonModalEditTeamspaceTitle).toHaveValue(data.title)
if (data.description != null) {
await expect(this.buttonModalEditTeamspaceDescription).toHaveText(data.description)
await expect(this.buttonModalEditTeamspaceDescription).toHaveValue(data.description)
}
await this.buttonModalEditTeamspaceClose.click()
}

Some files were not shown because too many files have changed in this diff Show More