diff --git a/models/controlled-documents/package.json b/models/controlled-documents/package.json index 675a5067d24..c9f2f59374a 100644 --- a/models/controlled-documents/package.json +++ b/models/controlled-documents/package.json @@ -55,6 +55,7 @@ "@hcengineering/training": "^0.1.0", "@hcengineering/notification": "^0.6.23", "@hcengineering/model-notification": "^0.6.0", - "@hcengineering/chunter": "^0.6.20" + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/text-editor": "^0.6.0" } } diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index 7f4329f792e..d095828e9f5 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -34,6 +34,7 @@ import notification from '@hcengineering/notification' import setting from '@hcengineering/setting' import tags from '@hcengineering/tags' import print from '@hcengineering/model-print' +import textEditor from '@hcengineering/text-editor' import documents from './plugin' import { definePermissions } from './permissions' @@ -866,6 +867,7 @@ export function createModel (builder: Builder): void { definePermissions(builder) defineNotifications(builder) defineSearch(builder) + defineTextActions(builder) } export function defineNotifications (builder: Builder): void { @@ -1018,5 +1020,17 @@ export function defineSearch (builder: Builder): void { ) } +export function defineTextActions (builder: Builder): void { + // Comment category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: documents.function.Comment, + icon: chunter.icon.Chunter, + visibilityTester: documents.function.IsCommentVisible, + label: chunter.string.Message, + category: 100, + index: 5 + }) +} + export { documentsOperation } from './migration' export { documents as default } diff --git a/models/controlled-documents/src/plugin.ts b/models/controlled-documents/src/plugin.ts index 18b4ef18148..08344d66ad7 100644 --- a/models/controlled-documents/src/plugin.ts +++ b/models/controlled-documents/src/plugin.ts @@ -22,6 +22,7 @@ import { type TagCategory } from '@hcengineering/tags' import { type AnyComponent } from '@hcengineering/ui' import { type ActionCategory, type ViewAction } from '@hcengineering/view' import { type NotificationType, type NotificationGroup } from '@hcengineering/notification' +import { type TextActionVisibleFunction, type TextActionFunction } from '@hcengineering/text-editor' export default mergeIds(documentsId, documents, { component: { @@ -55,7 +56,11 @@ export default mergeIds(documentsId, documents, { OtherTemplate: '' as Ref }, function: { - DocumentIdentifierProvider: '' as Resource<(client: Client, ref: Ref, doc?: T) => Promise> + DocumentIdentifierProvider: '' as Resource< + (client: Client, ref: Ref, doc?: T) => Promise + >, + Comment: '' as Resource, + IsCommentVisible: '' as Resource }, actionImpl: { AddCollaborativeSectionAbove: '' as ViewAction, diff --git a/models/text-editor/src/index.ts b/models/text-editor/src/index.ts index ae34cf4dfa3..46ae25bed4e 100644 --- a/models/text-editor/src/index.ts +++ b/models/text-editor/src/index.ts @@ -16,12 +16,19 @@ import { DOMAIN_MODEL } from '@hcengineering/core' import { type Builder, Model } from '@hcengineering/model' import core, { TDoc } from '@hcengineering/model-core' -import type { Asset, IntlString, Resource } from '@hcengineering/platform' +import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform' import { type ExtensionCreator, type TextEditorExtensionFactory, type RefInputAction, - type RefInputActionItem + type RefInputActionItem, + type TextEditorAction, + type TextActionFunction, + type TextActionVisibleFunction, + type TextActionActiveFunction, + type ActiveDescriptor, + type TogglerDescriptor, + type TextEditorActionKind } from '@hcengineering/text-editor' import textEditor from './plugin' @@ -45,6 +52,290 @@ export class TTextEditorExtensionFactory extends TDoc implements TextEditorExten create!: Resource } +@Model(textEditor.class.TextEditorAction, core.class.Doc, DOMAIN_MODEL) +export class TTextEditorAction extends TDoc implements TextEditorAction { + kind?: TextEditorActionKind + action!: TogglerDescriptor | Resource + visibilityTester?: Resource + icon!: Asset + isActive?: ActiveDescriptor | Resource + label!: IntlString + category!: number + index!: number +} + +function createHeaderAction (builder: Builder, level: number): void { + let icon: Asset + switch (level) { + case 1: + icon = textEditor.icon.Header1 + break + case 2: + icon = textEditor.icon.Header2 + break + case 3: + icon = textEditor.icon.Header3 + break + default: + throw new Error(`Not supported header level: ${level}`) + } + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleHeading', + params: { + level + } + }, + visibilityTester: textEditor.function.IsEditable, + icon, + isActive: { + name: 'heading', + params: { + level + } + }, + label: getEmbeddedLabel(`H${level}`), + category: 10, + index: 5 * level + }) +} + +function createImageAlignmentAction (builder: Builder, align: 'center' | 'left' | 'right'): void { + let icon: Asset + let label: IntlString + let index: number + switch (align) { + case 'left': + icon = textEditor.icon.AlignLeft + label = textEditor.string.AlignLeft + index = 5 + break + case 'center': + icon = textEditor.icon.AlignCenter + label = textEditor.string.AlignCenter + index = 10 + break + case 'right': + icon = textEditor.icon.AlignRight + label = textEditor.string.AlignRight + index = 15 + break + } + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + kind: 'image', + action: { + command: 'setImageAlignment', + params: { + align + } + }, + visibilityTester: textEditor.function.IsEditable, + icon, + isActive: { + name: 'image', + params: { + align + } + }, + label, + category: 80, + index + }) +} + export function createModel (builder: Builder): void { - builder.createModel(TRefInputActionItem, TTextEditorExtensionFactory) + builder.createModel(TRefInputActionItem, TTextEditorExtensionFactory, TTextEditorAction) + + createHeaderAction(builder, 1) + createHeaderAction(builder, 2) + createHeaderAction(builder, 3) + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleBold' + }, + icon: textEditor.icon.Bold, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'bold' + }, + label: textEditor.string.Bold, + category: 20, + index: 5 + }) + + // Decoration category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleItalic' + }, + icon: textEditor.icon.Italic, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'italic' + }, + label: textEditor.string.Italic, + category: 20, + index: 10 + }) + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleStrike' + }, + icon: textEditor.icon.Strikethrough, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'strike' + }, + label: textEditor.string.Strikethrough, + category: 20, + index: 15 + }) + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleUnderline' + }, + icon: textEditor.icon.Underline, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'underline' + }, + label: textEditor.string.Underlined, + category: 20, + index: 20 + }) + + // Link category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: textEditor.function.FormatLink, + icon: textEditor.icon.Link, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'link' + }, + label: textEditor.string.Link, + category: 30, + index: 5 + }) + + // List category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleOrderedList' + }, + icon: textEditor.icon.ListNumber, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'orderedList' + }, + label: textEditor.string.OrderedList, + category: 40, + index: 5 + }) + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleBulletList' + }, + icon: textEditor.icon.ListBullet, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'bulletList' + }, + label: textEditor.string.BulletedList, + category: 40, + index: 10 + }) + + // Quote category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleBlockquote' + }, + icon: textEditor.icon.Quote, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'blockquote' + }, + label: textEditor.string.Blockquote, + category: 50, + index: 5 + }) + + // Code category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleCode' + }, + icon: textEditor.icon.Code, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'code' + }, + label: textEditor.string.Code, + category: 60, + index: 5 + }) + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: { + command: 'toggleCodeBlock' + }, + icon: textEditor.icon.CodeBlock, + visibilityTester: textEditor.function.IsEditable, + isActive: { + name: 'codeBlock' + }, + label: textEditor.string.CodeBlock, + category: 60, + index: 10 + }) + + // Table category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + action: textEditor.function.OpenTableOptions, + icon: textEditor.icon.TableProps, + visibilityTester: textEditor.function.IsEditableTableActive, + label: textEditor.string.TableOptions, + category: 70, + index: 5 + }) + + // Image align category + createImageAlignmentAction(builder, 'left') + createImageAlignmentAction(builder, 'center') + createImageAlignmentAction(builder, 'right') + + // Image view category + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + kind: 'image', + action: textEditor.function.OpenImage, + icon: textEditor.icon.ScaleOut, + label: textEditor.string.ViewImage, + category: 90, + index: 5 + }) + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + kind: 'image', + action: textEditor.function.ExpandImage, + icon: textEditor.icon.Expand, + label: textEditor.string.ViewOriginal, + category: 90, + index: 10 + }) + + builder.createDoc(textEditor.class.TextEditorAction, core.space.Model, { + kind: 'image', + action: textEditor.function.MoreImageActions, + visibilityTester: textEditor.function.IsEditable, + icon: textEditor.icon.MoreH, + label: textEditor.string.MoreActions, + category: 100, + index: 5 + }) } diff --git a/models/text-editor/src/plugin.ts b/models/text-editor/src/plugin.ts index 8bf731bf571..eaa4be16746 100644 --- a/models/text-editor/src/plugin.ts +++ b/models/text-editor/src/plugin.ts @@ -14,8 +14,22 @@ // limitations under the License. // -import { mergeIds } from '@hcengineering/platform' -// Import plugin directly to prevent .svelte components to being exposed to type typescript. -import textEditor, { textEditorId } from '@hcengineering/text-editor' +import { mergeIds, type Resource } from '@hcengineering/platform' +import textEditor, { + type TextActionFunction, + type TextActionVisibleFunction, + textEditorId +} from '@hcengineering/text-editor' -export default mergeIds(textEditorId, textEditor, {}) +export default mergeIds(textEditorId, textEditor, { + function: { + FormatLink: '' as Resource, + OpenTableOptions: '' as Resource, + OpenImage: '' as Resource, + ExpandImage: '' as Resource, + MoreImageActions: '' as Resource, + + IsEditableTableActive: '' as Resource, + IsEditable: '' as Resource + } +}) diff --git a/plugins/controlled-documents-resources/src/components/document/editors/CollaborativeSectionEditor.svelte b/plugins/controlled-documents-resources/src/components/document/editors/CollaborativeSectionEditor.svelte index 5d162277561..455713c91e0 100644 --- a/plugins/controlled-documents-resources/src/components/document/editors/CollaborativeSectionEditor.svelte +++ b/plugins/controlled-documents-resources/src/components/document/editors/CollaborativeSectionEditor.svelte @@ -17,13 +17,12 @@ import { createEventDispatcher, onDestroy, tick } from 'svelte' import { CollaborativeDocumentSection } from '@hcengineering/controlled-documents' import attachment, { Attachment } from '@hcengineering/attachment' - import chunter from '@hcengineering/chunter' import { navigate } from '@hcengineering/ui' import { CollaborativeDoc, Ref, generateId, Blob } from '@hcengineering/core' import view from '@hcengineering/view' import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform' import { getClient } from '@hcengineering/presentation' - import { Heading, TextNodeAction } from '@hcengineering/text-editor' + import { Editor, Heading } from '@hcengineering/text-editor' import { CollaboratorEditor, FocusExtension, @@ -31,7 +30,8 @@ IsEmptyContentExtension, NodeHighlightExtension, NodeHighlightType, - highlightUpdateCommand + highlightUpdateCommand, + getNodeElement } from '@hcengineering/text-editor-resources' import { getCollaborationUser, getObjectLinkFragment } from '@hcengineering/view-resources' @@ -46,8 +46,7 @@ $isEditable as isEditable, documentCommentsDisplayRequested, documentCommentsHighlightUpdated, - documentCommentsLocationNavigateRequested, - showAddCommentPopupFx + documentCommentsLocationNavigateRequested } from '../../../stores/editors/document' export let value: CollaborativeDocumentSection @@ -61,6 +60,7 @@ let textEditor: CollaboratorEditor let isFocused = false let isEmpty = true + let editor: Editor const handleRefreshHighlight = () => { if (!textEditor) { @@ -90,7 +90,7 @@ await tick() - const element = textEditor.getNodeElement(nodeId) + const element = getNodeElement(editor, nodeId) if (element) { element.scrollIntoView({ behavior: 'smooth' }) @@ -98,20 +98,6 @@ } }) - const handleGetNodeId = () => { - if (isEmpty || !textEditor) { - return null - } - if (!selectedNodeId) { - const nodeId = generateId() - if (textEditor.setNodeUuid(nodeId)) { - selectedNodeId = nodeId - } - } - - return selectedNodeId - } - const handleNodeHighlight = (id: string) => { if ($highlighted) { const { sectionKey, nodeId } = $highlighted @@ -127,30 +113,6 @@ return null } - const handleCommentAction = (): void => { - if (isEmpty) { - return - } - - if (!$canAddDocumentComments) { - return - } - - if (!selectedNodeId) { - selectedNodeId = handleGetNodeId() - } - - if (selectedNodeId) { - showAddCommentPopupFx({ - element: textEditor.getNodeElement(selectedNodeId), - nodeId: selectedNodeId, - sectionKey: value.key - }) - - textEditor.selectNode(selectedNodeId) - } - } - const handleShowDocumentComments = (uuid: string) => { if (!uuid) { return @@ -161,7 +123,7 @@ } documentCommentsDisplayRequested({ - element: textEditor.getNodeElement(uuid), + element: getNodeElement(editor, uuid), nodeId: uuid, sectionKey: value.key }) @@ -247,13 +209,6 @@ } } - const commentAction: TextNodeAction = { - id: '#comment', - icon: chunter.icon.Chunter, - label: chunter.string.Message, - action: handleCommentAction - } - onDestroy(() => { unsubscribeHighlightRefresh() unsubscribeNavigateToLocation() @@ -274,19 +229,19 @@ {#key value._id} (editor = e.detail)} on:open-document={async (event) => { const doc = await client.findOne(event.detail._class, { _id: event.detail._id }) if (doc != null) { diff --git a/plugins/controlled-documents-resources/src/index.ts b/plugins/controlled-documents-resources/src/index.ts index cc5fde62da8..9f8b24e17cf 100644 --- a/plugins/controlled-documents-resources/src/index.ts +++ b/plugins/controlled-documents-resources/src/index.ts @@ -117,6 +117,7 @@ import { createDocument, createTemplate } from './utils' +import { comment, isCommentVisible } from './text' export { DocumentStatusTag, DocumentTitle, DocumentVersionPresenter, StatePresenter } @@ -372,7 +373,9 @@ export default async (): Promise => ({ GetDocumentMetaLinkFragment: getDocumentMetaLinkFragment, IsLatestDraftDoc: isLatestDraftDoc, DocumentIdentifierProvider: documentIdentifierProvider, - ControlledDocumentTitleProvider: getControlledDocumentTitle + ControlledDocumentTitleProvider: getControlledDocumentTitle, + Comment: comment, + IsCommentVisible: isCommentVisible }, actionImpl: { AddCollaborativeSectionAbove: addCollaborativeSectionAbove, diff --git a/plugins/controlled-documents-resources/src/text.ts b/plugins/controlled-documents-resources/src/text.ts new file mode 100644 index 00000000000..d7b181e69ae --- /dev/null +++ b/plugins/controlled-documents-resources/src/text.ts @@ -0,0 +1,209 @@ +// +// 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 { type Editor } from '@tiptap/core' +import documents, { + ControlledDocumentState, + DocumentState, + type EditorMode, + type ControlledDocument, + type DocumentReviewRequest, + type DocumentSection +} from '@hcengineering/controlled-documents' +import { generateId, getCurrentAccount, type Ref } from '@hcengineering/core' +import { type PersonAccount } from '@hcengineering/contact' +import { RequestStatus } from '@hcengineering/request' +import { getClient } from '@hcengineering/presentation' +import { type ActionContext } from '@hcengineering/text-editor' +import { getNodeElement, selectNode, nodeUuidName } from '@hcengineering/text-editor-resources' + +import { getCurrentEmployee } from './utils' +import { showAddCommentPopupFx } from './stores/editors/document' +import { $editorMode } from './stores/editors/document/editor' + +async function getCurrentReviewRequest (doc: ControlledDocument): Promise { + const client = getClient() + + return await client.findOne(documents.class.DocumentReviewRequest, { + attachedTo: doc._id, + attachedToClass: doc._class, + status: RequestStatus.Active + }) +} + +async function getDocumentStateForCurrentUser ( + doc: ControlledDocument +): Promise { + if (doc == null) { + return null + } + + const reviewRequest = await getCurrentReviewRequest(doc) + + if (doc.controlledState === ControlledDocumentState.InReview) { + if (reviewRequest == null) { + return ControlledDocumentState.InReview + } + + const currentAccount = getCurrentAccount()._id as Ref + if (reviewRequest.approved?.includes(currentAccount)) { + return ControlledDocumentState.Reviewed + } + } + + if (doc.controlledState != null) { + return doc.controlledState + } + + return doc.state +} + +function isDocumentOwner (doc: ControlledDocument): boolean { + if (doc == null) { + return false + } + + const employee = getCurrentEmployee() + + return doc.owner === employee +} + +function isDocumentCoAuthor (doc: ControlledDocument): boolean { + if (doc == null) { + return false + } + + const employee = getCurrentEmployee() + + if (employee === undefined) { + return false + } + + return doc.coAuthors.includes(employee) +} + +function isDocumentReviewer (doc: ControlledDocument): boolean { + if (doc == null) { + return false + } + + const employee = getCurrentEmployee() + if (employee == null) { + return false + } + return doc.reviewers?.includes(employee) ?? false +} + +function canViewDocumentComments (doc: ControlledDocument, mode: EditorMode): boolean { + return ( + (isDocumentOwner(doc) || isDocumentCoAuthor(doc) || isDocumentReviewer(doc)) && + (mode === 'viewing' || mode === 'editing') + ) +} + +async function canAddDocumentComments (doc: ControlledDocument, mode: EditorMode): Promise { + if (!canViewDocumentComments(doc, mode)) { + return false + } + + const state = await getDocumentStateForCurrentUser(doc) + + if (state === DocumentState.Draft) { + return isDocumentOwner(doc) || isDocumentCoAuthor(doc) + } + + if (state === ControlledDocumentState.InReview) { + return isDocumentOwner(doc) || isDocumentCoAuthor(doc) || isDocumentReviewer(doc) + } + + return false +} + +function markNodeWithUuid (editor: Editor): string | undefined { + if (editor === undefined) { + return + } + + const nodeId = generateId() + editor.commands.setNodeUuid(nodeId) + + return nodeId +} + +export async function comment (editor: Editor, event: MouseEvent, ctx: ActionContext): Promise { + const { objectId, objectClass } = ctx + if (objectId === undefined || objectClass === undefined) { + return + } + + const client = getClient() + const section = (await client.findOne(objectClass, { + _id: objectId + })) as DocumentSection + + if (section === undefined) { + return + } + + let selectedNodeId = editor.extensionStorage[nodeUuidName].activeNodeUuid + + if (selectedNodeId == null) { + selectedNodeId = markNodeWithUuid(editor) + } + + if (selectedNodeId == null) { + return + } + + await showAddCommentPopupFx({ + element: getNodeElement(editor, selectedNodeId), + nodeId: selectedNodeId, + sectionKey: section.key + }) + + selectNode(editor, selectedNodeId) +} + +export async function isCommentVisible (editor: Editor, ctx: ActionContext): Promise { + const { objectId, objectClass } = ctx + if (objectId === undefined || objectClass === undefined) { + return false + } + + const client = getClient() + if (!client.getHierarchy().isDerived(objectClass, documents.class.DocumentSection)) { + return false + } + + const section = (await client.findOne(objectClass, { + _id: objectId + })) as DocumentSection + + if (section === undefined) { + return false + } + + const doc = await client.findOne(documents.class.ControlledDocument, { + _id: section.attachedTo as Ref + }) + + if (doc === undefined) { + return false + } + + // TODO: move editor mode tracking to a new extension! + const mode = $editorMode.getState() + + return await canAddDocumentComments(doc, mode) +} diff --git a/plugins/text-editor-assets/assets/icons.svg b/plugins/text-editor-assets/assets/icons.svg new file mode 100644 index 00000000000..afccd929860 --- /dev/null +++ b/plugins/text-editor-assets/assets/icons.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/text-editor-assets/package.json b/plugins/text-editor-assets/package.json index 2b9dcb33f22..b3dd6b01ce5 100644 --- a/plugins/text-editor-assets/package.json +++ b/plugins/text-editor-assets/package.json @@ -33,7 +33,8 @@ "@types/jest": "^29.5.5" }, "dependencies": { - "@hcengineering/platform": "^0.6.11" + "@hcengineering/platform": "^0.6.11", + "@hcengineering/text-editor": "^0.6.0" }, "repository": "https://github.com/hcenginneing/anticrm", "publishConfig": { diff --git a/plugins/text-editor-assets/src/index.ts b/plugins/text-editor-assets/src/index.ts index de8ae59e5e1..217115b6ce4 100644 --- a/plugins/text-editor-assets/src/index.ts +++ b/plugins/text-editor-assets/src/index.ts @@ -13,3 +13,29 @@ // See the License for the specific language governing permissions and // limitations under the License. // +import { loadMetadata } from '@hcengineering/platform' +import textEditor from '@hcengineering/text-editor' + +const icons = require('../assets/icons.svg') as string // eslint-disable-line +loadMetadata(textEditor.icon, { + Header1: `${icons}#header1`, + Header2: `${icons}#header2`, + Header3: `${icons}#header3`, + Underline: `${icons}#underline`, + Strikethrough: `${icons}#strikethrough`, + Bold: `${icons}#bold`, + Italic: `${icons}#italic`, + Link: `${icons}#link`, + ListNumber: `${icons}#listNumber`, + ListBullet: `${icons}#listBullet`, + Quote: `${icons}#quote`, + Code: `${icons}#code`, + CodeBlock: `${icons}#codeBlock`, + TableProps: `${icons}#tableProps`, + AlignLeft: `${icons}#alignLeft`, + AlignCenter: `${icons}#alignCenter`, + AlignRight: `${icons}#alignRight`, + MoreH: `${icons}#moreH`, + Expand: `${icons}#expand`, + ScaleOut: `${icons}#scaleOut` +}) diff --git a/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte b/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte index fb78e0c973e..7b8d62ecbdc 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte @@ -17,7 +17,7 @@ import { IntlString } from '@hcengineering/platform' import { KeyedAttribute, getAttribute, getClient } from '@hcengineering/presentation' import { AnySvelteComponent, registerFocus } from '@hcengineering/ui' - import textEditor, { CollaborationUser, RefAction, TextNodeAction } from '@hcengineering/text-editor' + import textEditor, { CollaborationUser, RefAction } from '@hcengineering/text-editor' import CollaborativeTextEditor from './CollaborativeTextEditor.svelte' import { FocusExtension } from './extension/focus' @@ -26,7 +26,6 @@ export let object: Doc export let key: KeyedAttribute export let readonly = false - export let textNodeActions: TextNodeAction[] = [] export let refActions: RefAction[] = [] export let user: CollaborationUser @@ -111,7 +110,6 @@ objectAttr={key.key} {user} {userComponent} - {textNodeActions} {refActions} {extensions} {attachFile} diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index 3c04ca22864..5f366b89ad9 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -51,23 +51,18 @@ CollaborationUser, RefAction, TextEditorCommandHandler, - TextEditorHandler, - TextFormatCategory, - TextNodeAction + TextEditorHandler } from '@hcengineering/text-editor' import { addTableHandler } from '../utils' import CollaborationUsers from './CollaborationUsers.svelte' - import ImageStyleToolbar from './ImageStyleToolbar.svelte' - import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte' + import TextEditorToolbar from './TextEditorToolbar.svelte' import { noSelectionRender, renderCursor } from './editor/collaboration' import { defaultEditorAttributes } from './editor/editorProps' import { EmojiExtension } from './extension/emoji' import { FileUploadExtension } from './extension/fileUploadExt' import { ImageUploadExtension } from './extension/imageUploadExt' import { InlineCommandsExtension } from './extension/inlineCommands' - import { InlinePopupExtension } from './extension/inlinePopup' - import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar' import { LeftMenuExtension } from './extension/leftMenu' import { type FileAttachFunction } from './extension/types' import { completionConfig, inlineCommandsConfig } from './extensions' @@ -92,16 +87,6 @@ export let placeholder: IntlString = textEditor.string.EditorPlaceholder export let extensions: AnyExtension[] = [] - export let textFormatCategories: TextFormatCategory[] = [ - TextFormatCategory.Heading, - TextFormatCategory.TextDecoration, - TextFormatCategory.Link, - TextFormatCategory.List, - TextFormatCategory.Quote, - TextFormatCategory.Code, - TextFormatCategory.Table - ] - export let textNodeActions: TextNodeAction[] = [] export let refActions: RefAction[] = [] export let editorAttributes: Record = {} @@ -263,25 +248,8 @@ editor.setEditable(editable, true) } - $: showTextStyleToolbar = - ((editable && textFormatCategories.length > 0) || textNodeActions.length > 0) && canShowPopups - - $: tippyOptions = { - zIndex: 100000, - popperOptions: { - modifiers: [ - { - name: 'preventOverflow', - options: { - boundary, - padding: 8, - altAxis: true, - tether: false - } - } - ] - } - } + // TODO: should be inside the editor + $: showToolbar = canShowPopups const optionalExtensions: AnyExtension[] = [] @@ -438,29 +406,22 @@ objectClass, objectSpace, history: false, - submit: false - }), - ...optionalExtensions, - Placeholder.configure({ placeholder: placeHolderStr }), - InlineStyleToolbarExtension.configure({ - tippyOptions, - element: textToolbarElement, - isSupported: () => showTextStyleToolbar - }), - InlinePopupExtension.configure({ - pluginKey: 'show-image-actions-popup', - element: imageToolbarElement, - tippyOptions: { - ...tippyOptions, - appendTo: () => boundary ?? element + submit: false, + toolbar: { + element: textToolbarElement, + boundary, + isHidden: () => !showToolbar }, - shouldShow: ({ editor }) => { - if (!editable || !canShowPopups) { - return false + image: { + toolbar: { + element: imageToolbarElement, + boundary, + isHidden: () => !showToolbar } - return editor.isActive('image') } }), + ...optionalExtensions, + Placeholder.configure({ placeholder: placeHolderStr }), Collaboration.configure({ document: ydoc, field @@ -544,29 +505,22 @@ {/if} - - - + + +
@@ -609,15 +563,6 @@ position: relative; } - .text-editor-toolbar { - margin: -0.5rem -0.25rem 0.5rem; - padding: 0.375rem; - background-color: var(--theme-comp-header-color); - border-radius: 0.5rem; - box-shadow: var(--button-shadow); - z-index: 1; - } - .textInput { flex-grow: 1; gap: 1rem; diff --git a/plugins/text-editor-resources/src/components/CollaboratorEditor.svelte b/plugins/text-editor-resources/src/components/CollaboratorEditor.svelte index 21876e368b2..b6d6b1a0a67 100644 --- a/plugins/text-editor-resources/src/components/CollaboratorEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaboratorEditor.svelte @@ -18,18 +18,11 @@ import { type Space, type Class, type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core' import { IntlString } from '@hcengineering/platform' import { AnySvelteComponent, IconSize, registerFocus } from '@hcengineering/ui' - import { AnyExtension, Editor, FocusPosition, getMarkRange } from '@tiptap/core' - import { TextSelection } from '@tiptap/pm/state' - import textEditor, { - CollaborationUser, - TextEditorCommandHandler, - TextFormatCategory, - TextNodeAction - } from '@hcengineering/text-editor' + import { AnyExtension, FocusPosition } from '@tiptap/core' + import textEditor, { CollaborationUser, TextEditorCommandHandler } from '@hcengineering/text-editor' import CollaborativeTextEditor from './CollaborativeTextEditor.svelte' import { FileAttachFunction } from './extension/types' - import { NodeUuidExtension, nodeElementQuerySelector } from './extension/nodeUuid' export let collaborativeDoc: CollaborativeDoc export let initialCollaborativeDoc: CollaborativeDoc | undefined = undefined @@ -49,7 +42,6 @@ export let placeholder: IntlString = textEditor.string.EditorPlaceholder export let overflow: 'auto' | 'none' = 'auto' - export let textNodeActions: TextNodeAction[] = [] export let editorAttributes: Record = {} export let onExtensions: () => AnyExtension[] = () => [] export let boundary: HTMLElement | undefined = undefined @@ -59,79 +51,12 @@ let element: HTMLElement - let editor: Editor | undefined let collaborativeEditor: CollaborativeTextEditor export function commands (): TextEditorCommandHandler | undefined { return collaborativeEditor?.commands() } - // TODO Not collaborative - export function getNodeElement (uuid: string): Element | null { - if (editor === undefined || uuid === '') { - return null - } - - return editor.view.dom.querySelector(nodeElementQuerySelector(uuid)) - } - - // TODO Not collaborative - export function selectNode (uuid: string): void { - if (editor === undefined) { - return - } - - const { doc, schema, tr } = editor.view.state - let foundNode = false - doc.descendants((node, pos) => { - if (foundNode) { - return false - } - - const nodeUuidMark = node.marks.find( - (mark) => mark.type.name === NodeUuidExtension.name && mark.attrs[NodeUuidExtension.name] === uuid - ) - - if (nodeUuidMark === undefined) { - return - } - - foundNode = true - - // the first pos does not contain the mark, so we need to add 1 (pos + 1) to get the correct range - const range = getMarkRange(doc.resolve(pos + 1), schema.marks[NodeUuidExtension.name]) - - if (range === undefined) { - return false - } - - const [$start, $end] = [doc.resolve(range.from), doc.resolve(range.to)] - editor?.view.dispatch(tr.setSelection(new TextSelection($start, $end))) - focus() - }) - } - - // TODO Not collaborative - export function selectRange (from: number, to: number): void { - if (editor === undefined) { - return - } - - const { doc, tr } = editor.view.state - const [$start, $end] = [doc.resolve(from), doc.resolve(to)] - editor.view.dispatch(tr.setSelection(new TextSelection($start, $end))) - focus() - } - - // TODO Not collaborative - export function setNodeUuid (nodeId: string): boolean { - if (editor === undefined || editor.view.state.selection.empty || nodeId === '') { - return false - } - - return editor.chain().setNodeUuid(nodeId).run() - } - export function focus (position?: FocusPosition): void { collaborativeEditor?.focus(position) } @@ -178,28 +103,14 @@ {overflow} {boundary} {attachFile} - textFormatCategories={readonly - ? [] - : [ - TextFormatCategory.Heading, - TextFormatCategory.TextDecoration, - TextFormatCategory.Link, - TextFormatCategory.List, - TextFormatCategory.Quote, - TextFormatCategory.Code, - TextFormatCategory.Table - ]} extensions={[...onExtensions()]} - {textNodeActions} {canShowPopups} {editorAttributes} + on:editor on:update on:open-document on:blur on:focus={handleFocus} - on:editor={(evt) => { - editor = evt.detail - }} />
diff --git a/plugins/text-editor-resources/src/components/ImageStyleToolbar.svelte b/plugins/text-editor-resources/src/components/ImageStyleToolbar.svelte deleted file mode 100644 index 111634daf72..00000000000 --- a/plugins/text-editor-resources/src/components/ImageStyleToolbar.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - - -{#if editor} - {#if editor.isActive('image')} - - - -
- - -
- - {/if} -{/if} diff --git a/plugins/text-editor-resources/src/components/ReferenceInput.svelte b/plugins/text-editor-resources/src/components/ReferenceInput.svelte index 77b8eb4dbc4..a50f6a33db8 100644 --- a/plugins/text-editor-resources/src/components/ReferenceInput.svelte +++ b/plugins/text-editor-resources/src/components/ReferenceInput.svelte @@ -180,13 +180,6 @@ {onPaste} on:update placeholder={placeholder ?? textEditor.string.EditorPlaceholder} - textFormatCategories={[ - TextFormatCategory.TextDecoration, - TextFormatCategory.Link, - TextFormatCategory.List, - TextFormatCategory.Quote, - TextFormatCategory.Code - ]} />
{#if showActions || showSend} diff --git a/plugins/text-editor-resources/src/components/StyledTextEditor.svelte b/plugins/text-editor-resources/src/components/StyledTextEditor.svelte index df5195c1fe6..82c4b84209a 100644 --- a/plugins/text-editor-resources/src/components/StyledTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/StyledTextEditor.svelte @@ -39,15 +39,6 @@ export let editorAttributes: Record = {} export let extraActions: RefAction[] = [] export let boundary: HTMLElement | undefined = undefined - export let textFormatCategories: TextFormatCategory[] = [ - TextFormatCategory.Heading, - TextFormatCategory.TextDecoration, - TextFormatCategory.Link, - TextFormatCategory.List, - TextFormatCategory.Quote, - TextFormatCategory.Code, - TextFormatCategory.Table - ] let editor: TextEditor | undefined = undefined @@ -168,7 +159,6 @@ bind:content {placeholder} {extensions} - {textFormatCategories} bind:this={editor} on:value on:content={(ev) => { @@ -187,7 +177,6 @@ bind:content {placeholder} {extensions} - {textFormatCategories} bind:this={editor} on:value on:content={(ev) => { diff --git a/plugins/text-editor-resources/src/components/StyleButton.svelte b/plugins/text-editor-resources/src/components/TextActionButton.svelte similarity index 50% rename from plugins/text-editor-resources/src/components/StyleButton.svelte rename to plugins/text-editor-resources/src/components/TextActionButton.svelte index 9650974fa13..543888d7521 100644 --- a/plugins/text-editor-resources/src/components/StyleButton.svelte +++ b/plugins/text-editor-resources/src/components/TextActionButton.svelte @@ -1,5 +1,5 @@ diff --git a/plugins/text-editor-resources/src/components/TextEditorStyleToolbar.svelte b/plugins/text-editor-resources/src/components/TextEditorStyleToolbar.svelte deleted file mode 100644 index 7ee477577bd..00000000000 --- a/plugins/text-editor-resources/src/components/TextEditorStyleToolbar.svelte +++ /dev/null @@ -1,305 +0,0 @@ - - - -{#if editor} - {#each textFormatCategories as category, index} - {#if index > 0 && (category !== TextFormatCategory.Table || editor.isActive('table'))} -
- {/if} - {#if category === TextFormatCategory.Heading} - - - - {/if} - {#if category === TextFormatCategory.TextDecoration} - - - - - {/if} - {#if category === TextFormatCategory.Link} - - {/if} - {#if category === TextFormatCategory.List} - - - {/if} - {#if category === TextFormatCategory.Quote} - - {/if} - {#if category === TextFormatCategory.Code} - - - {/if} - {#if category === TextFormatCategory.Table} - {#if editor.isActive('table')} - - {/if} - {/if} - {/each} - {#if textFormatCategories.length > 0 && textNodeActions.length > 0} -
- {/if} - {#if textNodeActions.length > 0} - {#each textNodeActions as action} - { - void action.action({ editor }) - }} - /> - {/each} - {/if} -{/if} diff --git a/plugins/text-editor-resources/src/components/TextEditorToolbar.svelte b/plugins/text-editor-resources/src/components/TextEditorToolbar.svelte new file mode 100644 index 00000000000..f2eaa542564 --- /dev/null +++ b/plugins/text-editor-resources/src/components/TextEditorToolbar.svelte @@ -0,0 +1,114 @@ + + + +
+ {#if editor && visible && visibleActions.length > 0} +
+ {#each Object.values(categories) as category, index} + {#if index > 0} +
+ {/if} + + {#each category as [_, action]} + + {/each} + {/each} +
+ {/if} +
+ + diff --git a/plugins/text-editor-resources/src/components/extension/imageExt.ts b/plugins/text-editor-resources/src/components/extension/imageExt.ts index 4ab036f92f5..82523785fb0 100644 --- a/plugins/text-editor-resources/src/components/extension/imageExt.ts +++ b/plugins/text-editor-resources/src/components/extension/imageExt.ts @@ -12,11 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { FilePreviewPopup } from '@hcengineering/presentation' +import { getEmbeddedLabel } from '@hcengineering/platform' +import { FilePreviewPopup, getFileUrl } from '@hcengineering/presentation' import { ImageNode, type ImageOptions as ImageNodeOptions } from '@hcengineering/text' -import { showPopup } from '@hcengineering/ui' -import { nodeInputRule } from '@tiptap/core' +import textEditor from '@hcengineering/text-editor' +import { getEventPositionElement, SelectPopup, showPopup } from '@hcengineering/ui' +import { type Editor, nodeInputRule } from '@tiptap/core' +import { type BubbleMenuOptions } from '@tiptap/extension-bubble-menu' import { Plugin, PluginKey } from '@tiptap/pm/state' +import { InlinePopupExtension } from './inlinePopup' /** * @public @@ -26,7 +30,11 @@ export type ImageAlignment = 'center' | 'left' | 'right' /** * @public */ -export interface ImageOptions extends ImageNodeOptions {} +export type ImageOptions = ImageNodeOptions & { + toolbar?: Omit & { + isHidden?: () => boolean + } +} export interface ImageAlignmentOptions { align?: ImageAlignment @@ -158,5 +166,94 @@ export const ImageExtension = ImageNode.extend({ } }) ] + }, + + addExtensions () { + return [ + InlinePopupExtension.configure({ + ...this.options.toolbar, + shouldShow: ({ editor, view, state, oldState, from, to }) => { + if (this.options.toolbar?.isHidden?.() === true) { + return false + } + + if (editor.isDestroyed) { + return false + } + + // For some reason shouldShow might be called after dismount and + // after destroying the editor. We should handle this just no to have + // any errors in runtime + const editorElement = editor.view.dom + if (editorElement === null || editorElement === undefined) { + return false + } + + // When clicking on a element inside the bubble menu the editor "blur" event + // is called and the bubble menu item is focussed. In this case we should + // consider the menu as part of the editor and keep showing the menu + const isChildOfMenu = editorElement.contains(document.activeElement) + const hasEditorFocus = view.hasFocus() || isChildOfMenu + if (!hasEditorFocus) { + return false + } + + return editor.isActive('image') + } + }) + ] } }) + +export async function openImage (editor: Editor): Promise { + const attributes = editor.getAttributes('image') + const fileId = attributes['file-id'] ?? attributes.src + const fileName = attributes.alt ?? '' + await new Promise((resolve) => { + showPopup(FilePreviewPopup, { file: fileId, name: fileName, fullSize: true, showIcon: false }, 'centered', () => { + resolve() + }) + }) +} + +export async function expandImage (editor: Editor): Promise { + const attributes = editor.getAttributes('image') + const fileId = attributes['file-id'] ?? attributes.src + const url = getFileUrl(fileId) + window.open(url, '_blank') +} + +export async function moreImageActions (editor: Editor, event: MouseEvent): Promise { + const widthActions = ['25%', '50%', '75%', '100%', textEditor.string.Unset].map((it) => { + return { + id: `#imageWidth${it}`, + label: it === textEditor.string.Unset ? it : getEmbeddedLabel(it), + action: () => + editor.commands.setImageSize({ width: it === textEditor.string.Unset ? undefined : it, height: undefined }), + category: { + label: textEditor.string.Width + } + } + }) + + const actions = [...widthActions] + + await new Promise((resolve) => { + showPopup( + SelectPopup, + { + value: actions + }, + getEventPositionElement(event), + (val) => { + if (val !== undefined) { + const op = actions.find((it) => it.id === val) + if (op !== undefined) { + op.action() + resolve() + } + } + } + ) + }) +} diff --git a/plugins/text-editor-resources/src/components/extension/inlineStyleToolbar.ts b/plugins/text-editor-resources/src/components/extension/inlineToolbar.ts similarity index 80% rename from plugins/text-editor-resources/src/components/extension/inlineStyleToolbar.ts rename to plugins/text-editor-resources/src/components/extension/inlineToolbar.ts index 6b012bf5eae..d51c4c6cb16 100644 --- a/plugins/text-editor-resources/src/components/extension/inlineStyleToolbar.ts +++ b/plugins/text-editor-resources/src/components/extension/inlineToolbar.ts @@ -1,14 +1,19 @@ import { Extension, isTextSelection } from '@tiptap/core' import { type BubbleMenuOptions } from '@tiptap/extension-bubble-menu' import { PluginKey } from '@tiptap/pm/state' +import { type ActionContext } from '@hcengineering/text-editor' + import { InlinePopupExtension } from './inlinePopup' +export const inlineToolbarKey = 'toolbar' + export type InlineStyleToolbarOptions = BubbleMenuOptions & { - isSupported: () => boolean - canShowWithoutSelection?: boolean + isHidden?: () => boolean + ctx?: ActionContext } -export const InlineStyleToolbarExtension = Extension.create({ +export const InlineToolbarExtension = Extension.create({ + name: inlineToolbarKey, pluginKey: new PluginKey('inline-style-toolbar'), addExtensions () { const options: InlineStyleToolbarOptions = this.options @@ -17,7 +22,7 @@ export const InlineStyleToolbarExtension = Extension.create { - if (!this.options.isSupported()) { + if (this.options.isHidden?.() === true) { return false } @@ -26,7 +31,7 @@ export const InlineStyleToolbarExtension = Extension.create { diff --git a/plugins/text-editor-resources/src/components/extension/nodeUuid.ts b/plugins/text-editor-resources/src/components/extension/nodeUuid.ts index 1a91130ba4a..9c813991686 100644 --- a/plugins/text-editor-resources/src/components/extension/nodeUuid.ts +++ b/plugins/text-editor-resources/src/components/extension/nodeUuid.ts @@ -1,10 +1,17 @@ -import { type Command, type CommandProps, Mark, getMarkType, getMarksBetween, mergeAttributes } from '@tiptap/core' +import { + type CommandProps, + type Editor, + Mark, + getMarkRange, + getMarkType, + getMarksBetween, + mergeAttributes +} from '@tiptap/core' import { type Node, type Mark as ProseMirrorMark } from '@tiptap/pm/model' -import { type EditorState, Plugin, PluginKey } from '@tiptap/pm/state' +import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' -const NAME = 'node-uuid' - -export const nodeElementQuerySelector = (nodeUuid: string): string => `span[${NAME}='${nodeUuid}']` +export const nodeUuidName = 'node-uuid' +export const nodeElementQuerySelector = (nodeUuid: string): string => `span[${nodeUuidName}='${nodeUuid}']` export interface NodeUuidOptions { HTMLAttributes: Record @@ -12,21 +19,19 @@ export interface NodeUuidOptions { onNodeClicked?: (uuid: string) => void } -export interface NodeUuidCommands { - [NAME]: { - /** - * Add uuid mark - */ - setNodeUuid: (uuid: string) => ReturnType - /** - * Unset uuid mark - */ - unsetNodeUuid: () => ReturnType - } -} - declare module '@tiptap/core' { - interface Commands extends NodeUuidCommands {} + interface Commands { + [nodeUuidName]: { + /** + * Add uuid mark + */ + setNodeUuid: (uuid: string) => ReturnType + /** + * Unset uuid mark + */ + unsetNodeUuid: () => ReturnType + } + } } export interface NodeUuidStorage { @@ -60,7 +65,50 @@ export const findNodeUuidMark = (node: Node): ProseMirrorMark | undefined => { return } - return node.marks.find((mark) => mark.type.name === NAME && mark.attrs[NAME]) + return node.marks.find((mark) => mark.type.name === nodeUuidName && mark.attrs[nodeUuidName]) +} + +export function getNodeElement (editor: Editor, uuid: string): Element | null { + if (editor === undefined || uuid === '') { + return null + } + + return editor.view.dom.querySelector(nodeElementQuerySelector(uuid)) +} + +export function selectNode (editor: Editor, uuid: string): void { + if (editor === undefined) { + return + } + + const { doc, schema, tr } = editor.view.state + let foundNode = false + doc.descendants((node, pos) => { + if (foundNode) { + return false + } + + const nodeUuidMark = node.marks.find( + (mark) => mark.type.name === NodeUuidExtension.name && mark.attrs[NodeUuidExtension.name] === uuid + ) + + if (nodeUuidMark === undefined) { + return + } + + foundNode = true + + // the first pos does not contain the mark, so we need to add 1 (pos + 1) to get the correct range + const range = getMarkRange(doc.resolve(pos + 1), schema.marks[NodeUuidExtension.name]) + + if (range == null || typeof range !== 'object') { + return false + } + + const [$start, $end] = [doc.resolve(range.from), doc.resolve(range.to)] + editor?.view.dispatch(tr.setSelection(new TextSelection($start, $end))) + editor.commands.focus() + }) } /** @@ -68,7 +116,7 @@ export const findNodeUuidMark = (node: Node): ProseMirrorMark | undefined => { * Creates span node with attribute node-uuid */ export const NodeUuidExtension = Mark.create({ - name: NAME, + name: nodeUuidName, exitable: true, inclusive: false, addOptions () { @@ -79,9 +127,9 @@ export const NodeUuidExtension = Mark.create({ addAttributes () { return { - [NAME]: { + [nodeUuidName]: { default: null, - parseHTML: (el) => (el as HTMLSpanElement).getAttribute(NAME) + parseHTML: (el) => (el as HTMLSpanElement).getAttribute(nodeUuidName) } } }, @@ -89,9 +137,9 @@ export const NodeUuidExtension = Mark.create({ parseHTML () { return [ { - tag: `span[${NAME}]`, + tag: `span[${nodeUuidName}]`, getAttrs: (el) => { - const value = (el as HTMLSpanElement).getAttribute(NAME)?.trim() + const value = (el as HTMLSpanElement).getAttribute(nodeUuidName)?.trim() if (value === null || value === undefined || value.length === 0) { return false } @@ -119,12 +167,12 @@ export const NodeUuidExtension = Mark.create({ const to = Math.min(view.state.doc.content.size, pos + 1) const markRanges = getMarksBetween(from, to, view.state.doc)?.filter( - (markRange) => markRange.mark.type.name === NAME && markRange.from <= pos && markRange.to >= pos + (markRange) => markRange.mark.type.name === nodeUuidName && markRange.from <= pos && markRange.to >= pos ) ?? [] let nodeUuid: string | null = null if (markRanges.length > 0) { - nodeUuid = markRanges[0].mark.attrs[NAME] + nodeUuid = markRanges[0].mark.attrs[nodeUuidName] } if (nodeUuid !== null) { @@ -144,7 +192,7 @@ export const NodeUuidExtension = Mark.create({ }, addCommands () { - const result: NodeUuidCommands[typeof NAME] = { + return { setNodeUuid: (uuid: string) => ({ commands, state }: CommandProps) => { @@ -152,19 +200,17 @@ export const NodeUuidExtension = Mark.create({ if (selection.empty) { return false } - if (doc.rangeHasMark(selection.from, selection.to, getMarkType(NAME, state.schema))) { + if (doc.rangeHasMark(selection.from, selection.to, getMarkType(nodeUuidName, state.schema))) { return false } - return commands.setMark(this.name, { [NAME]: uuid }) + return commands.setMark(this.name, { [nodeUuidName]: uuid }) }, unsetNodeUuid: () => ({ commands }: CommandProps) => commands.unsetMark(this.name) } - - return result }, addStorage () { @@ -176,7 +222,7 @@ export const NodeUuidExtension = Mark.create({ onSelectionUpdate () { const activeNodeUuidMark = findSelectionNodeUuidMark(this.editor.state) const activeNodeUuid = - activeNodeUuidMark !== null && activeNodeUuidMark !== undefined ? activeNodeUuidMark.attrs[NAME] : null + activeNodeUuidMark !== null && activeNodeUuidMark !== undefined ? activeNodeUuidMark.attrs[nodeUuidName] : null if (this.storage.activeNodeUuid !== activeNodeUuid) { this.storage.activeNodeUuid = activeNodeUuid diff --git a/plugins/text-editor-resources/src/components/extension/table/table.ts b/plugins/text-editor-resources/src/components/extension/table/table.ts index f9f6426f319..55b69659233 100644 --- a/plugins/text-editor-resources/src/components/extension/table/table.ts +++ b/plugins/text-editor-resources/src/components/extension/table/table.ts @@ -16,9 +16,19 @@ import { type Editor } from '@tiptap/core' import TiptapTable from '@tiptap/extension-table' import { CellSelection } from '@tiptap/pm/tables' +import { getEventPositionElement, SelectPopup, showPopup } from '@hcengineering/ui' +import textEditor from '@hcengineering/text-editor' + import { SvelteNodeViewRenderer } from '../../node-view' import TableNodeView from './TableNodeView.svelte' import { isTableSelected } from './utils' +import AddColAfter from '../../icons/table/AddColAfter.svelte' +import AddColBefore from '../../icons/table/AddColBefore.svelte' +import AddRowAfter from '../../icons/table/AddRowAfter.svelte' +import AddRowBefore from '../../icons/table/AddRowBefore.svelte' +import DeleteCol from '../../icons/table/DeleteCol.svelte' +import DeleteRow from '../../icons/table/DeleteRow.svelte' +import DeleteTable from '../../icons/table/DeleteTable.svelte' export const Table = TiptapTable.extend({ addKeyboardShortcuts () { @@ -46,3 +56,95 @@ function handleDelete (editor: Editor): boolean { return false } + +export async function openTableOptions (editor: Editor, event: MouseEvent): Promise { + const ops = [ + { + id: '#addColumnBefore', + icon: AddColBefore, + label: textEditor.string.AddColumnBefore, + action: () => editor.commands.addColumnBefore(), + category: { + label: textEditor.string.CategoryColumn + } + }, + { + id: '#addColumnAfter', + icon: AddColAfter, + label: textEditor.string.AddColumnAfter, + action: () => editor.commands.addColumnAfter(), + category: { + label: textEditor.string.CategoryColumn + } + }, + + { + id: '#deleteColumn', + icon: DeleteCol, + label: textEditor.string.DeleteColumn, + action: () => editor.commands.deleteColumn(), + category: { + label: textEditor.string.CategoryColumn + } + }, + { + id: '#addRowBefore', + icon: AddRowBefore, + label: textEditor.string.AddRowBefore, + action: () => editor.commands.addRowBefore(), + category: { + label: textEditor.string.CategoryRow + } + }, + { + id: '#addRowAfter', + icon: AddRowAfter, + label: textEditor.string.AddRowAfter, + action: () => editor.commands.addRowAfter(), + category: { + label: textEditor.string.CategoryRow + } + }, + { + id: '#deleteRow', + icon: DeleteRow, + label: textEditor.string.DeleteRow, + action: () => editor.commands.deleteRow(), + category: { + label: textEditor.string.CategoryRow + } + }, + { + id: '#deleteTable', + icon: DeleteTable, + label: textEditor.string.DeleteTable, + action: () => editor.commands.deleteTable(), + category: { + label: textEditor.string.Table + } + } + ] + + await new Promise((resolve) => { + showPopup( + SelectPopup, + { + value: ops + }, + getEventPositionElement(event), + (val) => { + if (val !== undefined) { + const op = ops.find((it) => it.id === val) + if (op !== undefined) { + op.action() + } + } + resolve() + } + ) + }) +} + +export async function isEditableTableActive (editor: Editor): Promise { + return editor.isEditable && editor.isActive('table') +} diff --git a/plugins/text-editor-resources/src/components/icons/AlignCenter.svelte b/plugins/text-editor-resources/src/components/icons/AlignCenter.svelte deleted file mode 100644 index af67e83f0cf..00000000000 --- a/plugins/text-editor-resources/src/components/icons/AlignCenter.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/AlignLeft.svelte b/plugins/text-editor-resources/src/components/icons/AlignLeft.svelte deleted file mode 100644 index e2c7a0efe2f..00000000000 --- a/plugins/text-editor-resources/src/components/icons/AlignLeft.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/AlignRight.svelte b/plugins/text-editor-resources/src/components/icons/AlignRight.svelte deleted file mode 100644 index 8258ab57288..00000000000 --- a/plugins/text-editor-resources/src/components/icons/AlignRight.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Bold.svelte b/plugins/text-editor-resources/src/components/icons/Bold.svelte deleted file mode 100644 index 3f77316d963..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Bold.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Header1.svelte b/plugins/text-editor-resources/src/components/icons/Header1.svelte deleted file mode 100644 index c0d0d58d01d..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Header1.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Header2.svelte b/plugins/text-editor-resources/src/components/icons/Header2.svelte deleted file mode 100644 index b6a642a9bdc..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Header2.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Header3.svelte b/plugins/text-editor-resources/src/components/icons/Header3.svelte deleted file mode 100644 index 5b6c3cb11fc..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Header3.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Italic.svelte b/plugins/text-editor-resources/src/components/icons/Italic.svelte deleted file mode 100644 index 602a7355df6..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Italic.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Link.svelte b/plugins/text-editor-resources/src/components/icons/Link.svelte deleted file mode 100644 index 485fe45006c..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Link.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/ListBullet.svelte b/plugins/text-editor-resources/src/components/icons/ListBullet.svelte deleted file mode 100644 index f858b748489..00000000000 --- a/plugins/text-editor-resources/src/components/icons/ListBullet.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/ListNumber.svelte b/plugins/text-editor-resources/src/components/icons/ListNumber.svelte deleted file mode 100644 index 1d5d6f8aa21..00000000000 --- a/plugins/text-editor-resources/src/components/icons/ListNumber.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Quote.svelte b/plugins/text-editor-resources/src/components/icons/Quote.svelte deleted file mode 100644 index c1765f47ff6..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Quote.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/ScaleOut.svelte b/plugins/text-editor-resources/src/components/icons/ScaleOut.svelte deleted file mode 100644 index 549a9ecd2f5..00000000000 --- a/plugins/text-editor-resources/src/components/icons/ScaleOut.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Strikethrough.svelte b/plugins/text-editor-resources/src/components/icons/Strikethrough.svelte deleted file mode 100644 index 4c273324ca0..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Strikethrough.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - diff --git a/plugins/text-editor-resources/src/components/icons/Underline.svelte b/plugins/text-editor-resources/src/components/icons/Underline.svelte deleted file mode 100644 index 4ace13132e4..00000000000 --- a/plugins/text-editor-resources/src/components/icons/Underline.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - diff --git a/plugins/text-editor-resources/src/index.ts b/plugins/text-editor-resources/src/index.ts index 477a9f3f627..950ccd253d3 100644 --- a/plugins/text-editor-resources/src/index.ts +++ b/plugins/text-editor-resources/src/index.ts @@ -15,6 +15,11 @@ // import { type Resources } from '@hcengineering/platform' +import { formatLink } from './kits/default-kit' +import { isEditable } from './kits/editor-kit' +import { openTableOptions, isEditableTableActive } from './components/extension/table/table' +import { openImage, expandImage, moreImageActions } from './components/extension/imageExt' + export * from '@hcengineering/presentation/src/types' export { default as Collaboration } from './components/Collaboration.svelte' export { default as CollaborationDiffViewer } from './components/CollaborationDiffViewer.svelte' @@ -26,12 +31,12 @@ export { default as FullDescriptionBox } from './components/FullDescriptionBox.s export { default as MarkupDiffViewer } from './components/MarkupDiffViewer.svelte' export { default as ReferenceInput } from './components/ReferenceInput.svelte' export { default as StringDiffViewer } from './components/StringDiffViewer.svelte' -export { default as StyleButton } from './components/StyleButton.svelte' +export { default as StyleButton } from './components/TextActionButton.svelte' export { default as StyledTextArea } from './components/StyledTextArea.svelte' export { default as StyledTextBox } from './components/StyledTextBox.svelte' export { default as StyledTextEditor } from './components/StyledTextEditor.svelte' export { default as TextEditor } from './components/TextEditor.svelte' -export { default as TextEditorStyleToolbar } from './components/TextEditorStyleToolbar.svelte' +export { default as TextEditorToolbar } from './components/TextEditorToolbar.svelte' export { default as AttachIcon } from './components/icons/Attach.svelte' export { default as TableIcon } from './components/icons/Table.svelte' export { default as TableOfContents } from './components/toc/TableOfContents.svelte' @@ -55,15 +60,16 @@ export { } from './components/extension/nodeHighlight' export { NodeUuidExtension, - type NodeUuidCommands, type NodeUuidOptions, - type NodeUuidStorage + type NodeUuidStorage, + getNodeElement, + selectNode, + nodeUuidName } from './components/extension/nodeUuid' export { InlinePopupExtension } from './components/extension/inlinePopup' -export { InlineStyleToolbarExtension, type InlineStyleToolbarOptions } from './components/extension/inlineStyleToolbar' +export { InlineToolbarExtension, type InlineStyleToolbarOptions } from './components/extension/inlineToolbar' export { ImageExtension, type ImageOptions } from './components/extension/imageExt' export { ImageUploadExtension, type ImageUploadOptions } from './components/extension/imageUploadExt' - export * from './command/deleteAttachment' export { TiptapCollabProvider, @@ -73,6 +79,13 @@ export { export { formatCollaborativeDocumentId, formatPlatformDocumentId } from './provider/utils' export default async (): Promise => ({ - // component: { - // } + function: { + FormatLink: formatLink, + OpenTableOptions: openTableOptions, + OpenImage: openImage, + ExpandImage: expandImage, + MoreImageActions: moreImageActions, + IsEditableTableActive: isEditableTableActive, + IsEditable: isEditable + } }) diff --git a/plugins/text-editor-resources/src/kits/default-kit.ts b/plugins/text-editor-resources/src/kits/default-kit.ts index 730d8072c66..9e7e51ec0e6 100644 --- a/plugins/text-editor-resources/src/kits/default-kit.ts +++ b/plugins/text-editor-resources/src/kits/default-kit.ts @@ -14,7 +14,8 @@ // import { codeBlockOptions, codeOptions } from '@hcengineering/text' -import { Extension } from '@tiptap/core' +import { showPopup } from '@hcengineering/ui' +import { type Editor, Extension } from '@tiptap/core' import type { CodeOptions } from '@tiptap/extension-code' import type { CodeBlockOptions } from '@tiptap/extension-code-block' import type { HardBreakOptions } from '@tiptap/extension-hard-break' @@ -25,6 +26,8 @@ import Typography from '@tiptap/extension-typography' import Underline from '@tiptap/extension-underline' import StarterKit from '@tiptap/starter-kit' +import LinkPopup from '../components/LinkPopup.svelte' + export interface DefaultKitOptions { codeBlock?: Partial | false code?: Partial | false @@ -64,3 +67,15 @@ export const DefaultKit = Extension.create({ ] } }) + +export async function formatLink (editor: Editor): Promise { + const link = editor.getAttributes('link').href + + showPopup(LinkPopup, { link }, undefined, undefined, (newLink) => { + if (newLink === '') { + editor.chain().focus().extendMarkRange('link').unsetLink().run() + } else { + editor.chain().focus().extendMarkRange('link').setLink({ href: newLink }).run() + } + }) +} diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts index 1d825be249d..61206551b51 100644 --- a/plugins/text-editor-resources/src/kits/editor-kit.ts +++ b/plugins/text-editor-resources/src/kits/editor-kit.ts @@ -14,11 +14,10 @@ // import { type Class, type Space, type Doc, type Ref } from '@hcengineering/core' import { getResource } from '@hcengineering/platform' -import { type AnyExtension, Extension } from '@tiptap/core' +import { type AnyExtension, type Editor, Extension } from '@tiptap/core' import { type Level } from '@tiptap/extension-heading' import ListKeymap from '@tiptap/extension-list-keymap' import TableHeader from '@tiptap/extension-table-header' - import 'prosemirror-codemark/dist/codemark.css' import { getBlobRef, getClient } from '@hcengineering/presentation' import { CodeBlockExtension, codeBlockOptions, CodeExtension, codeOptions } from '@hcengineering/text' @@ -32,6 +31,34 @@ import { NodeUuidExtension } from '../components/extension/nodeUuid' import { Table, TableCell, TableRow } from '../components/extension/table' import { SubmitExtension, type SubmitOptions } from '../components/extension/submit' import { ParagraphExtension } from '../components/extension/paragraph' +import { InlineToolbarExtension } from '../components/extension/inlineToolbar' + +export interface EditorKitOptions extends DefaultKitOptions { + history?: false + file?: Partial | false + image?: + | (Partial & { + toolbar?: { + element: HTMLElement + boundary?: HTMLElement + isHidden?: () => boolean + } + }) + | false + mode?: 'full' | 'compact' + submit?: SubmitOptions | false + objectId?: Ref + objectClass?: Ref> + objectSpace?: Ref + toolbar?: + | { + element?: HTMLElement + boundary?: HTMLElement + appendTo?: HTMLElement | (() => HTMLElement) + isHidden?: () => boolean + } + | false +} const headingLevels: Level[] = [1, 2, 3] @@ -50,15 +77,31 @@ export const tableKitExtensions: KitExtension[] = [ [40, TableCell.configure({})] ] -export interface EditorKitOptions extends DefaultKitOptions { - history?: false - file?: Partial | false - image?: Partial | false - mode?: 'full' | 'compact' - submit?: SubmitOptions | false - objectId?: Ref - objectClass?: Ref> - objectSpace?: Ref +function getTippyOptions ( + boundary?: HTMLElement, + appendTo?: HTMLElement | (() => HTMLElement), + placement?: string, + offset?: number[] +): any { + return { + zIndex: 100000, + placement, + offset, + popperOptions: { + modifiers: [ + { + name: 'preventOverflow', + options: { + boundary, + padding: 8, + altAxis: true, + tether: false + } + } + ] + }, + ...(appendTo !== undefined ? { appendTo } : {}) + } } /** @@ -177,14 +220,37 @@ async function buildEditorKit (): Promise> { } if (this.options.image !== false) { + const imageOptions: ImageOptions = { + inline: true, + loadingImgSrc: + 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgd2lkdGg9IjMycHgiIGhlaWdodD0iMzJweCIgdmlld0JveD0iMCAwIDE2IDE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPg0KICAgIDxwYXRoIGQ9Im0gNCAxIGMgLTEuNjQ0NTMxIDAgLTMgMS4zNTU0NjkgLTMgMyB2IDEgaCAxIHYgLTEgYyAwIC0xLjEwOTM3NSAwLjg5MDYyNSAtMiAyIC0yIGggMSB2IC0xIHogbSAyIDAgdiAxIGggNCB2IC0xIHogbSA1IDAgdiAxIGggMSBjIDEuMTA5Mzc1IDAgMiAwLjg5MDYyNSAyIDIgdiAxIGggMSB2IC0xIGMgMCAtMS42NDQ1MzEgLTEuMzU1NDY5IC0zIC0zIC0zIHogbSAtNSA0IGMgLTAuNTUwNzgxIDAgLTEgMC40NDkyMTkgLTEgMSBzIDAuNDQ5MjE5IDEgMSAxIHMgMSAtMC40NDkyMTkgMSAtMSBzIC0wLjQ0OTIxOSAtMSAtMSAtMSB6IG0gLTUgMSB2IDQgaCAxIHYgLTQgeiBtIDEzIDAgdiA0IGggMSB2IC00IHogbSAtNC41IDIgbCAtMiAyIGwgLTEuNSAtMSBsIC0yIDIgdiAwLjUgYyAwIDAuNSAwLjUgMC41IDAuNSAwLjUgaCA3IHMgMC40NzI2NTYgLTAuMDM1MTU2IDAuNSAtMC41IHYgLTEgeiBtIC04LjUgMyB2IDEgYyAwIDEuNjQ0NTMxIDEuMzU1NDY5IDMgMyAzIGggMSB2IC0xIGggLTEgYyAtMS4xMDkzNzUgMCAtMiAtMC44OTA2MjUgLTIgLTIgdiAtMSB6IG0gMTMgMCB2IDEgYyAwIDEuMTA5Mzc1IC0wLjg5MDYyNSAyIC0yIDIgaCAtMSB2IDEgaCAxIGMgMS42NDQ1MzEgMCAzIC0xLjM1NTQ2OSAzIC0zIHYgLTEgeiBtIC04IDMgdiAxIGggNCB2IC0xIHogbSAwIDAiIGZpbGw9IiMyZTM0MzQiIGZpbGwtb3BhY2l0eT0iMC4zNDkwMiIvPg0KPC9zdmc+DQo=', + getBlobRef: async (file, name, size) => await getBlobRef(undefined, file, name, size), + HTMLAttributes: this.options.image?.HTMLAttributes ?? {}, + ...this.options.image + } + + if (this.options.image?.toolbar !== undefined) { + imageOptions.toolbar = { + ...this.options.image?.toolbar, + tippyOptions: getTippyOptions(this.options.image?.toolbar?.boundary) + } + } + + staticKitExtensions.push([800, ImageExtension.configure(imageOptions)]) + } + + if (this.options.toolbar !== false) { staticKitExtensions.push([ - 800, - ImageExtension.configure({ - inline: true, - loadingImgSrc: - 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgd2lkdGg9IjMycHgiIGhlaWdodD0iMzJweCIgdmlld0JveD0iMCAwIDE2IDE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPg0KICAgIDxwYXRoIGQ9Im0gNCAxIGMgLTEuNjQ0NTMxIDAgLTMgMS4zNTU0NjkgLTMgMyB2IDEgaCAxIHYgLTEgYyAwIC0xLjEwOTM3NSAwLjg5MDYyNSAtMiAyIC0yIGggMSB2IC0xIHogbSAyIDAgdiAxIGggNCB2IC0xIHogbSA1IDAgdiAxIGggMSBjIDEuMTA5Mzc1IDAgMiAwLjg5MDYyNSAyIDIgdiAxIGggMSB2IC0xIGMgMCAtMS42NDQ1MzEgLTEuMzU1NDY5IC0zIC0zIC0zIHogbSAtNSA0IGMgLTAuNTUwNzgxIDAgLTEgMC40NDkyMTkgLTEgMSBzIDAuNDQ5MjE5IDEgMSAxIHMgMSAtMC40NDkyMTkgMSAtMSBzIC0wLjQ0OTIxOSAtMSAtMSAtMSB6IG0gLTUgMSB2IDQgaCAxIHYgLTQgeiBtIDEzIDAgdiA0IGggMSB2IC00IHogbSAtNC41IDIgbCAtMiAyIGwgLTEuNSAtMSBsIC0yIDIgdiAwLjUgYyAwIDAuNSAwLjUgMC41IDAuNSAwLjUgaCA3IHMgMC40NzI2NTYgLTAuMDM1MTU2IDAuNSAtMC41IHYgLTEgeiBtIC04LjUgMyB2IDEgYyAwIDEuNjQ0NTMxIDEuMzU1NDY5IDMgMyAzIGggMSB2IC0xIGggLTEgYyAtMS4xMDkzNzUgMCAtMiAtMC44OTA2MjUgLTIgLTIgdiAtMSB6IG0gMTMgMCB2IDEgYyAwIDEuMTA5Mzc1IC0wLjg5MDYyNSAyIC0yIDIgaCAtMSB2IDEgaCAxIGMgMS42NDQ1MzEgMCAzIC0xLjM1NTQ2OSAzIC0zIHYgLTEgeiBtIC04IDMgdiAxIGggNCB2IC0xIHogbSAwIDAiIGZpbGw9IiMyZTM0MzQiIGZpbGwtb3BhY2l0eT0iMC4zNDkwMiIvPg0KPC9zdmc+DQo=', - getBlobRef: async (file, name, size) => await getBlobRef(undefined, file, name, size), - ...this.options.image + 900, + InlineToolbarExtension.configure({ + tippyOptions: getTippyOptions(this.options.toolbar?.boundary, this.options.toolbar?.appendTo), + element: this.options.toolbar?.element, + isHidden: this.options.toolbar?.isHidden, + ctx: { + objectId: this.options.objectId, + objectClass: this.options.objectClass, + objectSpace: this.options.objectSpace + } }) ]) } @@ -203,3 +269,7 @@ async function buildEditorKit (): Promise> { }) }) } + +export async function isEditable (editor: Editor): Promise { + return editor.isEditable +} diff --git a/plugins/text-editor/src/plugin.ts b/plugins/text-editor/src/plugin.ts index c3811427a54..f58f59e391a 100644 --- a/plugins/text-editor/src/plugin.ts +++ b/plugins/text-editor/src/plugin.ts @@ -15,8 +15,9 @@ // import { type Class, type Ref } from '@hcengineering/core' -import { type IntlString, type Metadata, type Plugin, plugin } from '@hcengineering/platform' -import { type TextEditorExtensionFactory, type RefInputActionItem } from './types' +import { type Asset, type IntlString, type Metadata, type Plugin, plugin } from '@hcengineering/platform' + +import { type TextEditorExtensionFactory, type RefInputActionItem, TextEditorAction } from './types' /** * @public @@ -26,7 +27,8 @@ export const textEditorId = 'text-editor' as Plugin export default plugin(textEditorId, { class: { RefInputActionItem: '' as Ref>, - TextEditorExtensionFactory: '' as Ref> + TextEditorExtensionFactory: '' as Ref>, + TextEditorAction: '' as Ref> }, metadata: { CollaboratorUrl: '' as Metadata @@ -84,5 +86,27 @@ export default plugin(textEditorId, { Image: '' as IntlString, SeparatorLine: '' as IntlString, TodoList: '' as IntlString + }, + icon: { + Header1: '' as Asset, + Header2: '' as Asset, + Header3: '' as Asset, + Underline: '' as Asset, + Strikethrough: '' as Asset, + Bold: '' as Asset, + Italic: '' as Asset, + Link: '' as Asset, + ListNumber: '' as Asset, + ListBullet: '' as Asset, + Quote: '' as Asset, + Code: '' as Asset, + CodeBlock: '' as Asset, + TableProps: '' as Asset, + AlignLeft: '' as Asset, + AlignCenter: '' as Asset, + AlignRight: '' as Asset, + MoreH: '' as Asset, + Expand: '' as Asset, + ScaleOut: '' as Asset } }) diff --git a/plugins/text-editor/src/types.ts b/plugins/text-editor/src/types.ts index d7a10906f49..95586510152 100644 --- a/plugins/text-editor/src/types.ts +++ b/plugins/text-editor/src/types.ts @@ -1,11 +1,11 @@ import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' -import { type Account, type Doc, type Markup, type Ref } from '@hcengineering/core' +import { type Class, type Space, type Account, type Doc, type Markup, type Ref } from '@hcengineering/core' import type { AnySvelteComponent } from '@hcengineering/ui/src/types' import { type RelativePosition } from 'yjs' import { type AnyExtension, type Content, type Editor, type SingleCommands } from '@tiptap/core' import { type ParseOptions } from '@tiptap/pm/model' -export type { AnyExtension } from '@tiptap/core' +export type { AnyExtension, Editor } from '@tiptap/core' /** * @public */ @@ -73,13 +73,6 @@ export interface RefAction { disabled?: boolean } -export interface TextNodeAction { - id: string - label?: IntlString - icon: Asset | AnySvelteComponent - action: (params: { editor: Editor }) => Promise | void -} - /** * @public */ @@ -102,6 +95,15 @@ export interface TextEditorCommandProps { */ export type TextEditorCommand = (props: TextEditorCommandProps) => boolean +/** + * @public + */ +export interface ActionContext { + objectId?: Ref + objectClass?: Ref> + objectSpace?: Ref +} + /** * @public */ @@ -145,3 +147,50 @@ export interface TextEditorExtensionFactory extends Doc { index: number create: Resource } + +/** + * Action handler for text editor action + */ +export type TextActionFunction = (editor: Editor, event: MouseEvent, ctx: ActionContext) => Promise + +/** + * Handler to determine whether the text action is visible + */ +export type TextActionVisibleFunction = (editor: Editor, ctx: ActionContext) => Promise + +/** + * Handler to determine whether the text action is active + */ +export type TextActionActiveFunction = (editor: Editor) => Promise + +/** + * Describes toggle handler for a text action + */ +export interface TogglerDescriptor { + command: string + params?: any +} + +/** + * Describes isActive handler for a text action + */ +export interface ActiveDescriptor { + name: string + params?: any +} + +export type TextEditorActionKind = 'text' | 'image' + +/** + * Defines a text action for text action editor + */ +export interface TextEditorAction extends Doc { + kind?: TextEditorActionKind + action: TogglerDescriptor | Resource + visibilityTester?: Resource + icon: Asset + isActive?: ActiveDescriptor | Resource + label: IntlString + category: number + index: number +}