Refactor TextEditor: Move bubble menu toolbar in separate component (#3647)

Signed-off-by: Anna No <anna.no@xored.com>
This commit is contained in:
Anna No 2023-09-01 13:15:27 +07:00 committed by GitHub
parent 4bdb5ebcd3
commit 125d77f944
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 402 additions and 942 deletions

View File

@ -15,59 +15,30 @@
//
-->
<script lang="ts">
import { getEmbeddedLabel, IntlString, translate } from '@hcengineering/platform'
import { IntlString, translate } from '@hcengineering/platform'
import { Editor, Extension, HTMLContent } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import { Level } from '@tiptap/extension-heading'
import Placeholder from '@tiptap/extension-placeholder'
import { Plugin, PluginKey, Transaction } from 'prosemirror-state'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import { getCurrentAccount, Markup } from '@hcengineering/core'
import {
getEventPositionElement,
getPlatformColorForText,
IconObjects,
IconSize,
SelectPopup,
showPopup,
themeStore
} from '@hcengineering/ui'
import { getPlatformColorForText, IconObjects, IconSize, themeStore } from '@hcengineering/ui'
import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs'
import StyleButton from './StyleButton.svelte'
import presentation from '@hcengineering/presentation'
import { DecorationSet } from 'prosemirror-view'
import textEditorPlugin from '../plugin'
import { CollaborationIds, FORMAT_MODES, FormatMode } from '../types'
import Bold from './icons/Bold.svelte'
import Code from './icons/Code.svelte'
import CodeBlock from './icons/CodeBlock.svelte'
import { CollaborationIds, TextFormatCategory } from '../types'
import { getContext } from 'svelte'
import { calculateDecorations } from './diff/decorations'
import { defaultExtensions, headingLevels, mInsertTable } from './extensions'
import Header from './icons/Header.svelte'
import IconTable from './icons/IconTable.svelte'
import Italic from './icons/Italic.svelte'
import LinkEl from './icons/Link.svelte'
import ListBullet from './icons/ListBullet.svelte'
import ListNumber from './icons/ListNumber.svelte'
import Quote from './icons/Quote.svelte'
import Strikethrough from './icons/Strikethrough.svelte'
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'
import LinkPopup from './LinkPopup.svelte'
import { defaultExtensions } from './extensions'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
export let documentId: string
export let readonly = false
@ -127,50 +98,16 @@
editor.commands.insertContent(text as HTMLContent)
}
export function toggleBold () {
editor.commands.toggleBold()
}
export function toggleItalic () {
editor.commands.toggleItalic()
}
export function toggleStrike () {
editor.commands.toggleStrike()
}
export function getHTML (): string | undefined {
if (editor) {
return editor.getHTML()
}
}
export function getLink () {
return editor.getAttributes('link').href
}
export function unsetLink () {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}
export function setLink (link: string) {
editor.chain().focus().extendMarkRange('link').setLink({ href: link }).run()
}
export function checkIsSelectionEmpty () {
return editor.view.state.selection.empty
}
export function toggleOrderedList () {
editor.commands.toggleOrderedList()
}
export function toggleBulletList () {
editor.commands.toggleBulletList()
}
export function toggleBlockquote () {
editor.commands.toggleBlockquote()
}
export function toggleCode () {
editor.commands.toggleCode()
}
export function toggleCodeBlock () {
editor.commands.toggleCodeBlock()
}
let needFocus = false
let focused = false
@ -266,11 +203,9 @@
},
onUpdate: (op: { editor: Editor; transaction: Transaction }) => {
dispatch('content', editor.getHTML())
updateFormattingState()
},
onSelectionUpdate: () => {
dispatch('selection-update')
updateFormattingState()
}
})
@ -291,192 +226,6 @@
}
})
let activeModes = new Set<FormatMode>()
let isSelectionEmpty = true
export function checkIsActive (formatMode: FormatMode) {
return editor.isActive(formatMode)
}
let headingLevel = 0
function updateFormattingState () {
activeModes = new Set(FORMAT_MODES.filter(checkIsActive))
for (const l of headingLevels) {
if (editor.isActive('heading', { level: l })) {
headingLevel = l
activeModes.add('heading')
}
}
if (!activeModes.has('heading')) {
headingLevel = 0
}
isSelectionEmpty = editor.view.state.selection.empty
}
function getToggler (toggle: () => void) {
return () => {
toggle()
needFocus = true
updateFormattingState()
}
}
function toggleHeader (event: MouseEvent) {
if (activeModes.has('heading')) {
editor.commands.toggleHeading({ level: headingLevel as Level })
needFocus = true
updateFormattingState()
} else {
showPopup(
SelectPopup,
{
value: Array.from(headingLevels).map((it) => ({ id: it.toString(), text: it.toString() }))
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
editor.commands.toggleHeading({ level: parseInt(val) as Level })
needFocus = true
updateFormattingState()
}
}
)
}
}
function insertTable (event: MouseEvent) {
showPopup(
SelectPopup,
{
value: [
{ id: '#delete', label: presentation.string.Remove },
...mInsertTable.map((it) => ({ id: it.label, text: it.label }))
]
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
if (val === '#delete') {
editor.commands.deleteTable()
needFocus = true
updateFormattingState()
return
}
const tab = mInsertTable.find((it) => it.label === val)
if (tab) {
editor.commands.insertTable({
cols: tab.cols,
rows: tab.rows,
withHeaderRow: tab.header
})
needFocus = true
updateFormattingState()
}
}
}
)
}
function tableOptions (event: MouseEvent) {
const ops = [
{
id: '#addColumnBefore',
icon: AddColBefore,
label: textEditorPlugin.string.AddColumnBefore,
action: () => editor.commands.addColumnBefore(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#addColumnAfter',
icon: AddColAfter,
label: textEditorPlugin.string.AddColumnAfter,
action: () => editor.commands.addColumnAfter(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#deleteColumn',
icon: DeleteCol,
label: textEditorPlugin.string.DeleteColumn,
action: () => editor.commands.deleteColumn(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#addRowBefore',
icon: AddRowBefore,
label: textEditorPlugin.string.AddRowBefore,
action: () => editor.commands.addRowBefore(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#addRowAfter',
icon: AddRowAfter,
label: textEditorPlugin.string.AddRowAfter,
action: () => editor.commands.addRowAfter(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#deleteRow',
icon: DeleteRow,
label: textEditorPlugin.string.DeleteRow,
action: () => editor.commands.deleteRow(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#deleteTable',
icon: DeleteTable,
label: textEditorPlugin.string.DeleteTable,
action: () => editor.commands.deleteTable(),
category: {
label: textEditorPlugin.string.Table
}
}
]
showPopup(
SelectPopup,
{
value: ops
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
const op = ops.find((it) => it.id === val)
if (op) {
op.action()
needFocus = true
updateFormattingState()
}
}
}
)
}
async function formatLink (): Promise<void> {
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()
}
})
}
let showDiff = true
</script>
@ -485,99 +234,22 @@
{#if isFormatting && !readonly}
<div class="formatPanelRef formatPanel flex-between clear-mins">
<div class="flex-row-center buttons-group xsmall-gap">
<StyleButton
icon={Header}
size={buttonSize}
selected={activeModes.has('heading')}
showTooltip={{ label: getEmbeddedLabel(`H${headingLevel}`) }}
on:click={toggleHeader}
<TextEditorStyleToolbar
textEditor={editor}
textFormatCategories={[
TextFormatCategory.Heading,
TextFormatCategory.TextDecoration,
TextFormatCategory.Link,
TextFormatCategory.List,
TextFormatCategory.Quote,
TextFormatCategory.Code,
TextFormatCategory.Table
]}
formatButtonSize={buttonSize}
on:focus={() => {
needFocus = true
}}
/>
<StyleButton
icon={Bold}
size={buttonSize}
selected={activeModes.has('bold')}
showTooltip={{ label: textEditorPlugin.string.Bold }}
on:click={getToggler(toggleBold)}
/>
<StyleButton
icon={Italic}
size={buttonSize}
selected={activeModes.has('italic')}
showTooltip={{ label: textEditorPlugin.string.Italic }}
on:click={getToggler(toggleItalic)}
/>
<StyleButton
icon={Strikethrough}
size={buttonSize}
selected={activeModes.has('strike')}
showTooltip={{ label: textEditorPlugin.string.Strikethrough }}
on:click={getToggler(toggleStrike)}
/>
<StyleButton
icon={LinkEl}
size={buttonSize}
selected={activeModes.has('link')}
disabled={isSelectionEmpty && !activeModes.has('link')}
showTooltip={{ label: textEditorPlugin.string.Link }}
on:click={formatLink}
/>
<div class="buttons-divider" />
<StyleButton
icon={ListNumber}
size={buttonSize}
selected={activeModes.has('orderedList')}
showTooltip={{ label: textEditorPlugin.string.OrderedList }}
on:click={getToggler(toggleOrderedList)}
/>
<StyleButton
icon={ListBullet}
size={buttonSize}
selected={activeModes.has('bulletList')}
showTooltip={{ label: textEditorPlugin.string.BulletedList }}
on:click={getToggler(toggleBulletList)}
/>
<div class="buttons-divider" />
<StyleButton
icon={Quote}
size={buttonSize}
selected={activeModes.has('blockquote')}
showTooltip={{ label: textEditorPlugin.string.Blockquote }}
on:click={getToggler(toggleBlockquote)}
/>
<div class="buttons-divider" />
<StyleButton
icon={Code}
size={buttonSize}
selected={activeModes.has('code')}
showTooltip={{ label: textEditorPlugin.string.Code }}
on:click={getToggler(toggleCode)}
/>
<StyleButton
icon={CodeBlock}
size={buttonSize}
selected={activeModes.has('codeBlock')}
showTooltip={{ label: textEditorPlugin.string.CodeBlock }}
on:click={getToggler(toggleCodeBlock)}
/>
<StyleButton
icon={IconTable}
iconProps={{ style: 'table' }}
size={buttonSize}
selected={activeModes.has('table')}
on:click={insertTable}
showTooltip={{ label: textEditorPlugin.string.InsertTable }}
/>
{#if activeModes.has('table')}
<StyleButton
icon={IconTable}
iconProps={{ style: 'tableProps' }}
size={buttonSize}
on:click={tableOptions}
showTooltip={{ label: textEditorPlugin.string.TableOptions }}
/>
{/if}
</div>
<div class="flex-grow" />
{#if comparedVersion !== undefined}

View File

@ -17,7 +17,6 @@
import { getClient } from '@hcengineering/presentation'
import {
AnySvelteComponent,
Button,
EmojiPopup,
Icon,
IconEmoji,
@ -32,22 +31,11 @@
import { createEventDispatcher } from 'svelte'
import { Completion } from '../Completion'
import textEditorPlugin from '../plugin'
import { FORMAT_MODES, FormatMode, RefAction, RefInputActionItem, TextEditorHandler } from '../types'
import LinkPopup from './LinkPopup.svelte'
import { RefAction, RefInputActionItem, TextEditorHandler, TextFormatCategory } from '../types'
import TextEditor from './TextEditor.svelte'
import { completionConfig } from './extensions'
import Attach from './icons/Attach.svelte'
import CodeBlock from './icons/CodeBlock.svelte'
import ListBullet from './icons/ListBullet.svelte'
import ListNumber from './icons/ListNumber.svelte'
import Quote from './icons/Quote.svelte'
import Bold from './icons/Bold.svelte'
import Code from './icons/Code.svelte'
import Italic from './icons/Italic.svelte'
import Link from './icons/Link.svelte'
import RIMention from './icons/RIMention.svelte'
import RIStrikethrough from './icons/RIStrikethrough.svelte'
import Underline from './icons/Underline.svelte'
import Send from './icons/Send.svelte'
const dispatch = createEventDispatcher()
@ -64,10 +52,7 @@
const client = getClient()
let textEditor: TextEditor
let textEditorToolbar: HTMLElement
let activeModes = new Set<FormatMode>()
let isSelectionEmpty = true
let isEmpty = true
$: setContent(content)
@ -142,34 +127,6 @@
a.action(evt?.target as HTMLElement, editorHandler)
}
function updateFormattingState () {
if (textEditor?.checkIsActive === undefined) {
return
}
activeModes = new Set(FORMAT_MODES.filter(textEditor.checkIsActive))
isSelectionEmpty = textEditor.checkIsSelectionEmpty()
}
function getToggler (toggle: () => void) {
return () => {
toggle()
textEditor.focus()
updateFormattingState()
}
}
async function formatLink (): Promise<void> {
const link = textEditor.getLink()
showPopup(LinkPopup, { link }, undefined, undefined, (newLink) => {
if (newLink === '') {
textEditor.unsetLink()
} else {
textEditor.setLink(newLink)
}
})
}
// Focusable control with index
let focused = false
export let focusIndex = -1
@ -198,92 +155,6 @@
</script>
<div class="ref-container">
<div class="formatPanel buttons-group xsmall-gap mb-4" class:withoutTopBorder bind:this={textEditorToolbar}>
<Button
icon={Bold}
kind={'ghost'}
size={'small'}
selected={activeModes.has('bold')}
showTooltip={{ label: textEditorPlugin.string.Bold }}
on:click={getToggler(textEditor.toggleBold)}
/>
<Button
icon={Italic}
kind={'ghost'}
size={'small'}
selected={activeModes.has('italic')}
showTooltip={{ label: textEditorPlugin.string.Italic }}
on:click={getToggler(textEditor.toggleItalic)}
/>
<Button
icon={RIStrikethrough}
kind={'ghost'}
size={'small'}
selected={activeModes.has('strike')}
showTooltip={{ label: textEditorPlugin.string.Strikethrough }}
on:click={getToggler(textEditor.toggleStrike)}
/>
<Button
icon={Underline}
kind={'ghost'}
size={'small'}
selected={activeModes.has('underline')}
showTooltip={{ label: textEditorPlugin.string.Underlined }}
on:click={getToggler(textEditor.toggleUnderline)}
/>
<Button
icon={Link}
kind={'ghost'}
size={'small'}
selected={activeModes.has('link')}
disabled={isSelectionEmpty && !activeModes.has('link')}
showTooltip={{ label: textEditorPlugin.string.Link }}
on:click={formatLink}
/>
<div class="buttons-divider" />
<Button
icon={ListNumber}
kind={'ghost'}
size={'small'}
selected={activeModes.has('orderedList')}
showTooltip={{ label: textEditorPlugin.string.OrderedList }}
on:click={getToggler(textEditor.toggleOrderedList)}
/>
<Button
icon={ListBullet}
kind={'ghost'}
size={'small'}
selected={activeModes.has('bulletList')}
showTooltip={{ label: textEditorPlugin.string.BulletedList }}
on:click={getToggler(textEditor.toggleBulletList)}
/>
<div class="buttons-divider" />
<Button
icon={Quote}
kind={'ghost'}
size={'small'}
selected={activeModes.has('blockquote')}
showTooltip={{ label: textEditorPlugin.string.Blockquote }}
on:click={getToggler(textEditor.toggleBlockquote)}
/>
<div class="buttons-divider" />
<Button
icon={Code}
kind={'ghost'}
size={'small'}
selected={activeModes.has('code')}
showTooltip={{ label: textEditorPlugin.string.Code }}
on:click={getToggler(textEditor.toggleCode)}
/>
<Button
icon={CodeBlock}
kind={'ghost'}
size={'small'}
selected={activeModes.has('codeBlock')}
showTooltip={{ label: textEditorPlugin.string.CodeBlock }}
on:click={getToggler(textEditor.toggleCodeBlock)}
/>
</div>
<div class="textInput" class:withoutTopBorder>
<div class="inputMsg">
<TextEditor
@ -307,10 +178,15 @@
dispatch('focus', focused)
}}
extensions={[completionPlugin]}
on:selection-update={updateFormattingState}
on:update
placeholder={placeholder ?? textEditorPlugin.string.EditorPlaceholder}
{textEditorToolbar}
textFormatCategories={[
TextFormatCategory.TextDecoration,
TextFormatCategory.Link,
TextFormatCategory.List,
TextFormatCategory.Quote,
TextFormatCategory.Code
]}
/>
</div>
{#if showSend}
@ -371,10 +247,6 @@
</div>
<style lang="scss">
.buttons-divider {
height: 1rem;
max-height: 1rem;
}
.icon-button {
display: flex;
justify-content: center;
@ -400,21 +272,6 @@
flex-direction: column;
min-height: 4.5rem;
.formatPanelRef {
padding: 0.5rem;
background-color: var(--theme-comp-header-color);
border: 1px solid var(--theme-refinput-divider);
border-radius: 0.5rem 0.5rem 0 0;
border-bottom: 0;
&.withoutTopBorder {
border-radius: 0;
}
& + .textInput {
border-top: none;
}
}
.textInput {
display: flex;
justify-content: space-between;
@ -484,14 +341,5 @@
}
}
}
.formatPanel {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--theme-popup-shadow);
z-index: 1;
}
}
</style>

View File

@ -13,46 +13,14 @@
// limitations under the License.
-->
<script lang="ts">
import { Asset, getEmbeddedLabel, getResource, IntlString } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import {
AnySvelteComponent,
EmojiPopup,
getEventPositionElement,
IconEmoji,
IconSize,
Scroller,
SelectPopup,
showPopup
} from '@hcengineering/ui'
import { Level } from '@tiptap/extension-heading'
import { Asset, getResource, IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { AnySvelteComponent, EmojiPopup, IconEmoji, IconSize, Scroller, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import textEditorPlugin from '../plugin'
import { FORMAT_MODES, FormatMode, RefInputAction, RefInputActionItem, TextEditorHandler } from '../types'
import { headingLevels, mInsertTable } from './extensions'
import { RefInputAction, RefInputActionItem, TextEditorHandler, TextFormatCategory } from '../types'
import Attach from './icons/Attach.svelte'
import CodeBlock from './icons/CodeBlock.svelte'
import Header1 from './icons/Header1.svelte'
import Header2 from './icons/Header2.svelte'
import IconTable from './icons/IconTable.svelte'
import ListBullet from './icons/ListBullet.svelte'
import ListNumber from './icons/ListNumber.svelte'
import Quote from './icons/Quote.svelte'
import Bold from './icons/Bold.svelte'
import Code from './icons/Code.svelte'
import Italic from './icons/Italic.svelte'
import Link from './icons/Link.svelte'
import RIStrikethrough from './icons/RIStrikethrough.svelte'
import Underline from './icons/Underline.svelte'
import { AnyExtension } from '@tiptap/core'
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'
import LinkPopup from './LinkPopup.svelte'
import StyleButton from './StyleButton.svelte'
import TextEditor from './TextEditor.svelte'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
@ -68,14 +36,21 @@
export let isScrollable: boolean = true
export let focusable: boolean = false
export let maxHeight: 'max' | 'card' | 'limited' | string | undefined = undefined
export let withoutTopBorder = false
export let enableFormatting = false
export let autofocus = false
export let full = false
export let extensions: AnyExtension[] = []
export let textFormatCategories: TextFormatCategory[] = [
TextFormatCategory.Heading,
TextFormatCategory.TextDecoration,
TextFormatCategory.Link,
TextFormatCategory.List,
TextFormatCategory.Quote,
TextFormatCategory.Code,
TextFormatCategory.Table
]
let textEditor: TextEditor
let textEditorToolbar: HTMLElement
let isEmpty = true
let contentHeight: number
@ -122,9 +97,6 @@
? 'max-content'
: maxHeight
let activeModes = new Set<FormatMode>()
let isSelectionEmpty = true
interface RefAction {
label: IntlString
icon: Asset | AnySvelteComponent
@ -176,43 +148,6 @@
actions = defActions.concat(...cont).sort((a, b) => a.order - b.order)
})
function updateFormattingState () {
if (textEditor?.checkIsActive === undefined) {
return
}
activeModes = new Set(FORMAT_MODES.filter(textEditor.checkIsActive))
for (const l of headingLevels) {
if (textEditor.checkIsActive('heading', { level: l })) {
activeModes.add('heading')
if (l === 1) {
activeModes.add('heading1')
} else if (l === 2) {
activeModes.add('heading2')
}
}
}
isSelectionEmpty = textEditor.checkIsSelectionEmpty()
}
function getToggler (toggle: () => void) {
return () => {
toggle()
textEditor.focus()
updateFormattingState()
}
}
async function formatLink (): Promise<void> {
const link = textEditor.getLink()
showPopup(LinkPopup, { link }, undefined, undefined, (newLink) => {
if (newLink === '') {
textEditor.unsetLink()
} else {
textEditor.setLink(newLink)
}
})
}
const editorHandler: TextEditorHandler = {
insertText: (text) => {
textEditor.insertText(text)
@ -234,135 +169,6 @@
needFocus = false
}
function getHeaderToggler (level: Level) {
return () => {
textEditor.toggleHeading({ level })
needFocus = true
updateFormattingState()
}
}
function insertTable (event: MouseEvent) {
showPopup(
SelectPopup,
{
value: [
{ id: '#delete', label: presentation.string.Remove },
...mInsertTable.map((it) => ({ id: it.label, text: it.label }))
]
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
if (val === '#delete') {
textEditor.deleteTable()
needFocus = true
updateFormattingState()
return
}
const tab = mInsertTable.find((it) => it.label === val)
if (tab) {
textEditor.insertTable({
cols: tab.cols,
rows: tab.rows,
withHeaderRow: tab.header
})
needFocus = true
updateFormattingState()
}
}
}
)
}
function tableOptions (event: MouseEvent) {
const ops = [
{
id: '#addColumnBefore',
icon: AddColBefore,
label: textEditorPlugin.string.AddColumnBefore,
action: () => textEditor.addColumnBefore(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#addColumnAfter',
icon: AddColAfter,
label: textEditorPlugin.string.AddColumnAfter,
action: () => textEditor.addColumnAfter(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#deleteColumn',
icon: DeleteCol,
label: textEditorPlugin.string.DeleteColumn,
action: () => textEditor.deleteColumn(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#addRowBefore',
icon: AddRowBefore,
label: textEditorPlugin.string.AddRowBefore,
action: () => textEditor.addRowBefore(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#addRowAfter',
icon: AddRowAfter,
label: textEditorPlugin.string.AddRowAfter,
action: () => textEditor.addRowAfter(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#deleteRow',
icon: DeleteRow,
label: textEditorPlugin.string.DeleteRow,
action: () => textEditor.deleteRow(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#deleteTable',
icon: DeleteTable,
label: textEditorPlugin.string.DeleteTable,
action: () => textEditor.deleteTable(),
category: {
label: textEditorPlugin.string.Table
}
}
]
showPopup(
SelectPopup,
{
value: ops
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
const op = ops.find((it) => it.id === val)
if (op) {
op.action()
needFocus = true
updateFormattingState()
}
}
}
)
}
const buttonsGap = 'small-gap'
$: buttonsHeight =
@ -387,113 +193,6 @@
tabindex="-1"
on:click|preventDefault|stopPropagation={() => (needFocus = true)}
>
<div class="formatPanel buttons-group xsmall-gap mb-4" class:withoutTopBorder bind:this={textEditorToolbar}>
<StyleButton
icon={Header1}
size={formatButtonSize}
selected={activeModes.has('heading1')}
showTooltip={{ label: getEmbeddedLabel('H1') }}
on:click={getHeaderToggler(1)}
/>
<StyleButton
icon={Header2}
size={formatButtonSize}
selected={activeModes.has('heading2')}
showTooltip={{ label: getEmbeddedLabel('H2') }}
on:click={getHeaderToggler(2)}
/>
<StyleButton
icon={Bold}
size={formatButtonSize}
selected={activeModes.has('bold')}
showTooltip={{ label: textEditorPlugin.string.Bold }}
on:click={getToggler(textEditor.toggleBold)}
/>
<StyleButton
icon={Italic}
size={formatButtonSize}
selected={activeModes.has('italic')}
showTooltip={{ label: textEditorPlugin.string.Italic }}
on:click={getToggler(textEditor.toggleItalic)}
/>
<StyleButton
icon={RIStrikethrough}
size={formatButtonSize}
selected={activeModes.has('strike')}
showTooltip={{ label: textEditorPlugin.string.Strikethrough }}
on:click={getToggler(textEditor.toggleStrike)}
/>
<StyleButton
icon={Underline}
size={formatButtonSize}
selected={activeModes.has('underline')}
showTooltip={{ label: textEditorPlugin.string.Underlined }}
on:click={getToggler(textEditor.toggleUnderline)}
/>
<StyleButton
icon={Link}
size={formatButtonSize}
selected={activeModes.has('link')}
disabled={isSelectionEmpty && !activeModes.has('link')}
showTooltip={{ label: textEditorPlugin.string.Link }}
on:click={formatLink}
/>
<div class="buttons-divider" />
<StyleButton
icon={ListNumber}
size={formatButtonSize}
selected={activeModes.has('orderedList')}
showTooltip={{ label: textEditorPlugin.string.OrderedList }}
on:click={getToggler(textEditor.toggleOrderedList)}
/>
<StyleButton
icon={ListBullet}
size={formatButtonSize}
selected={activeModes.has('bulletList')}
showTooltip={{ label: textEditorPlugin.string.BulletedList }}
on:click={getToggler(textEditor.toggleBulletList)}
/>
<div class="buttons-divider" />
<StyleButton
icon={Quote}
size={formatButtonSize}
selected={activeModes.has('blockquote')}
showTooltip={{ label: textEditorPlugin.string.Blockquote }}
on:click={getToggler(textEditor.toggleBlockquote)}
/>
<div class="buttons-divider" />
<StyleButton
icon={Code}
size={formatButtonSize}
selected={activeModes.has('code')}
showTooltip={{ label: textEditorPlugin.string.Code }}
on:click={getToggler(textEditor.toggleCode)}
/>
<StyleButton
icon={CodeBlock}
size={formatButtonSize}
selected={activeModes.has('codeBlock')}
showTooltip={{ label: textEditorPlugin.string.CodeBlock }}
on:click={getToggler(textEditor.toggleCodeBlock)}
/>
<StyleButton
icon={IconTable}
iconProps={{ style: 'table' }}
size={formatButtonSize}
selected={activeModes.has('table')}
on:click={insertTable}
showTooltip={{ label: textEditorPlugin.string.InsertTable }}
/>
{#if activeModes.has('table')}
<StyleButton
icon={IconTable}
iconProps={{ style: 'tableProps' }}
size={formatButtonSize}
on:click={tableOptions}
showTooltip={{ label: textEditorPlugin.string.TableOptions }}
/>
{/if}
</div>
<div class="textInput" class:focusable>
<div
bind:clientHeight={contentHeight}
@ -507,6 +206,7 @@
bind:content
{placeholder}
{extensions}
{textFormatCategories}
bind:this={textEditor}
bind:isEmpty
on:value
@ -518,8 +218,6 @@
on:blur
on:focus
supportSubmit={false}
{textEditorToolbar}
on:selection-update={updateFormattingState}
/>
</Scroller>
{:else}
@ -527,6 +225,7 @@
bind:content
{placeholder}
{extensions}
{textFormatCategories}
bind:this={textEditor}
bind:isEmpty
on:value
@ -538,8 +237,6 @@
on:blur
on:focus
supportSubmit={false}
{textEditorToolbar}
on:selection-update={updateFormattingState}
/>
{/if}
</div>
@ -628,14 +325,5 @@
}
}
}
.formatPanel {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--theme-popup-shadow);
z-index: 1;
}
}
</style>

View File

@ -18,23 +18,24 @@
import { FocusPosition } from '@tiptap/core'
import { AnyExtension, Editor, Extension, HTMLContent, isTextSelection } from '@tiptap/core'
import { Level } from '@tiptap/extension-heading'
import Placeholder from '@tiptap/extension-placeholder'
import BubbleMenu from '@tiptap/extension-bubble-menu'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import textEditorPlugin from '../plugin'
import { FormatMode } from '../types'
import { defaultExtensions } from './extensions'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { themeStore } from '@hcengineering/ui'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
import { TextFormatCategory } from '../types'
export let content: string = ''
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let extensions: AnyExtension[] = []
export let textFormatCategories: TextFormatCategory[] = []
export let supportSubmit = true
export let isEmpty = true
export let textEditorToolbar: HTMLElement | null = null
let element: HTMLElement
let editor: Editor
@ -75,79 +76,6 @@
export function insertText (text: string): void {
editor.commands.insertContent(text as HTMLContent)
}
export function checkIsActive (formatMode: FormatMode, attributes?: {} | undefined) {
return editor.isActive(formatMode, attributes)
}
export function toggleBold () {
editor.commands.toggleBold()
}
export function toggleItalic () {
editor.commands.toggleItalic()
}
export function toggleStrike () {
editor.commands.toggleStrike()
}
export function toggleUnderline () {
editor.commands.toggleUnderline()
}
export function getLink () {
return editor.getAttributes('link').href
}
export function unsetLink () {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}
export function setLink (link: string) {
editor.chain().focus().extendMarkRange('link').setLink({ href: link }).run()
}
export function checkIsSelectionEmpty () {
return editor.view.state.selection.empty
}
export function toggleOrderedList () {
editor.commands.toggleOrderedList()
}
export function toggleBulletList () {
editor.commands.toggleBulletList()
}
export function toggleBlockquote () {
editor.commands.toggleBlockquote()
}
export function toggleCode () {
editor.commands.toggleCode()
}
export function toggleCodeBlock () {
editor.commands.toggleCodeBlock()
}
export function toggleHeading (attributes: { level: Level }) {
editor.commands.toggleHeading(attributes)
}
export function addColumnBefore () {
editor.commands.addColumnBefore()
}
export function addColumnAfter () {
editor.commands.addColumnAfter()
}
export function deleteColumn () {
editor.commands.deleteColumn()
}
export function addRowBefore () {
editor.commands.addRowBefore()
}
export function addRowAfter () {
editor.commands.addRowAfter()
}
export function deleteRow () {
editor.commands.deleteRow()
}
export function deleteTable () {
editor.commands.deleteTable()
}
export function insertTable (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) {
editor.commands.insertTable({
cols: options?.cols ?? 2,
rows: options?.rows ?? 1,
withHeaderRow: options?.withHeaderRow
})
}
export function isEmptyContent (): boolean {
return isEmpty
@ -157,6 +85,7 @@
let focused = false
let posFocus: FocusPosition | undefined = undefined
let showContextMenu = false
let textEditorToolbar: HTMLElement
export function focus (position?: FocusPosition): void {
posFocus = position
@ -216,7 +145,9 @@
...extensions,
BubbleMenu.configure({
element: textEditorToolbar,
tippyOptions: {
maxWidth: '38rem'
},
// to override shouldShow behaviour a little
// I need to copypaste original function and make a little change
// with showContextMenu falg
@ -318,6 +249,15 @@
}
</script>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={textEditorToolbar}>
<TextEditorStyleToolbar
textEditor={editor}
{textFormatCategories}
on:focus={() => {
needFocus = true
}}
/>
</div>
<div class="select-text" style="width: 100%;" on:mousedown={onEditorClick} bind:this={element} />
<style lang="scss" global>
@ -363,4 +303,13 @@
margin: 0;
}
}
.formatPanel {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--theme-popup-shadow);
z-index: 1;
}
</style>

View File

@ -0,0 +1,321 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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 { createEventDispatcher } from 'svelte'
import { getEmbeddedLabel } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { getEventPositionElement, IconSize, SelectPopup, showPopup } from '@hcengineering/ui'
import { Editor } from '@tiptap/core'
import { Level } from '@tiptap/extension-heading'
import textEditorPlugin from '../plugin'
import { TextFormatCategory } from '../types'
import { mInsertTable } from './extensions'
import Bold from './icons/Bold.svelte'
import Code from './icons/Code.svelte'
import CodeBlock from './icons/CodeBlock.svelte'
import Header1 from './icons/Header1.svelte'
import Header2 from './icons/Header2.svelte'
import IconTable from './icons/IconTable.svelte'
import Italic from './icons/Italic.svelte'
import Link from './icons/Link.svelte'
import ListBullet from './icons/ListBullet.svelte'
import ListNumber from './icons/ListNumber.svelte'
import Quote from './icons/Quote.svelte'
import RIStrikethrough from './icons/RIStrikethrough.svelte'
import Underline from './icons/Underline.svelte'
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'
import LinkPopup from './LinkPopup.svelte'
import StyleButton from './StyleButton.svelte'
export let formatButtonSize: IconSize = 'small'
export let textEditor: Editor
export let textFormatCategories: TextFormatCategory[] = []
const dispatch = createEventDispatcher()
function getToggler (toggle: () => void) {
return () => {
toggle()
dispatch('focus')
}
}
async function formatLink (): Promise<void> {
const link = textEditor.getAttributes('link').href
showPopup(LinkPopup, { link }, undefined, undefined, (newLink) => {
if (newLink === '') {
textEditor.chain().focus().extendMarkRange('link').unsetLink().run()
} else {
textEditor.chain().focus().extendMarkRange('link').setLink({ href: newLink }).run()
}
})
}
function getHeaderToggler (level: Level) {
return () => {
textEditor.commands.toggleHeading({ level })
dispatch('focus')
}
}
function insertTable (event: MouseEvent) {
showPopup(
SelectPopup,
{
value: [
{ id: '#delete', label: presentation.string.Remove },
...mInsertTable.map((it) => ({ id: it.label, text: it.label }))
]
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
if (val === '#delete') {
textEditor.commands.deleteTable()
dispatch('focus')
return
}
const tab = mInsertTable.find((it) => it.label === val)
if (tab) {
textEditor.commands.insertTable({
cols: tab.cols,
rows: tab.rows,
withHeaderRow: tab.header
})
dispatch('focus')
}
}
}
)
}
function tableOptions (event: MouseEvent) {
const ops = [
{
id: '#addColumnBefore',
icon: AddColBefore,
label: textEditorPlugin.string.AddColumnBefore,
action: () => textEditor.commands.addColumnBefore(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#addColumnAfter',
icon: AddColAfter,
label: textEditorPlugin.string.AddColumnAfter,
action: () => textEditor.commands.addColumnAfter(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#deleteColumn',
icon: DeleteCol,
label: textEditorPlugin.string.DeleteColumn,
action: () => textEditor.commands.deleteColumn(),
category: {
label: textEditorPlugin.string.CategoryColumn
}
},
{
id: '#addRowBefore',
icon: AddRowBefore,
label: textEditorPlugin.string.AddRowBefore,
action: () => textEditor.commands.addRowBefore(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#addRowAfter',
icon: AddRowAfter,
label: textEditorPlugin.string.AddRowAfter,
action: () => textEditor.commands.addRowAfter(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#deleteRow',
icon: DeleteRow,
label: textEditorPlugin.string.DeleteRow,
action: () => textEditor.commands.deleteRow(),
category: {
label: textEditorPlugin.string.CategoryRow
}
},
{
id: '#deleteTable',
icon: DeleteTable,
label: textEditorPlugin.string.DeleteTable,
action: () => textEditor.commands.deleteTable(),
category: {
label: textEditorPlugin.string.Table
}
}
]
showPopup(
SelectPopup,
{
value: ops
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
const op = ops.find((it) => it.id === val)
if (op) {
op.action()
dispatch('focus')
}
}
}
)
}
</script>
{#if textEditor}
{#each textFormatCategories as category, index}
{#if category === TextFormatCategory.Heading}
<StyleButton
icon={Header1}
size={formatButtonSize}
selected={textEditor.isActive('heading', { level: 1 })}
showTooltip={{ label: getEmbeddedLabel('H1') }}
on:click={getHeaderToggler(1)}
/>
<StyleButton
icon={Header2}
size={formatButtonSize}
selected={textEditor.isActive('heading', { level: 2 })}
showTooltip={{ label: getEmbeddedLabel('H2') }}
on:click={getHeaderToggler(2)}
/>
{/if}
{#if category === TextFormatCategory.TextDecoration}
<StyleButton
icon={Bold}
size={formatButtonSize}
selected={textEditor.isActive('bold')}
showTooltip={{ label: textEditorPlugin.string.Bold }}
on:click={getToggler(textEditor.commands.toggleBold)}
/>
<StyleButton
icon={Italic}
size={formatButtonSize}
selected={textEditor.isActive('italic')}
showTooltip={{ label: textEditorPlugin.string.Italic }}
on:click={getToggler(textEditor.commands.toggleItalic)}
/>
<StyleButton
icon={RIStrikethrough}
size={formatButtonSize}
selected={textEditor.isActive('strike')}
showTooltip={{ label: textEditorPlugin.string.Strikethrough }}
on:click={getToggler(textEditor.commands.toggleStrike)}
/>
<StyleButton
icon={Underline}
size={formatButtonSize}
selected={textEditor.isActive('underline')}
showTooltip={{ label: textEditorPlugin.string.Underlined }}
on:click={getToggler(textEditor.commands.toggleUnderline)}
/>
{/if}
{#if category === TextFormatCategory.Link}
<StyleButton
icon={Link}
size={formatButtonSize}
selected={textEditor.isActive('link')}
disabled={textEditor.view.state.selection.empty && !textEditor.isActive('link')}
showTooltip={{ label: textEditorPlugin.string.Link }}
on:click={formatLink}
/>
{/if}
{#if category === TextFormatCategory.List}
<StyleButton
icon={ListNumber}
size={formatButtonSize}
selected={textEditor.isActive('orderedList')}
showTooltip={{ label: textEditorPlugin.string.OrderedList }}
on:click={getToggler(textEditor.commands.toggleOrderedList)}
/>
<StyleButton
icon={ListBullet}
size={formatButtonSize}
selected={textEditor.isActive('bulletList')}
showTooltip={{ label: textEditorPlugin.string.BulletedList }}
on:click={getToggler(textEditor.commands.toggleBulletList)}
/>
{/if}
{#if category === TextFormatCategory.Quote}
<StyleButton
icon={Quote}
size={formatButtonSize}
selected={textEditor.isActive('blockquote')}
showTooltip={{ label: textEditorPlugin.string.Blockquote }}
on:click={getToggler(textEditor.commands.toggleBlockquote)}
/>
{/if}
{#if category === TextFormatCategory.Code}
<StyleButton
icon={Code}
size={formatButtonSize}
selected={textEditor.isActive('code')}
showTooltip={{ label: textEditorPlugin.string.Code }}
on:click={getToggler(textEditor.commands.toggleCode)}
/>
<StyleButton
icon={CodeBlock}
size={formatButtonSize}
selected={textEditor.isActive('codeBlock')}
showTooltip={{ label: textEditorPlugin.string.CodeBlock }}
on:click={getToggler(textEditor.commands.toggleCodeBlock)}
/>
{/if}
{#if category === TextFormatCategory.Table}
<StyleButton
icon={IconTable}
iconProps={{ style: 'table' }}
size={formatButtonSize}
selected={textEditor.isActive('table')}
on:click={insertTable}
showTooltip={{ label: textEditorPlugin.string.InsertTable }}
/>
{#if textEditor.isActive('table')}
<StyleButton
icon={IconTable}
iconProps={{ style: 'tableProps' }}
size={formatButtonSize}
on:click={tableOptions}
showTooltip={{ label: textEditorPlugin.string.TableOptions }}
/>
{/if}
{/if}
{#if index < textFormatCategories.length - 1}
<div class="buttons-divider" />
{/if}
{/each}
{/if}

View File

@ -42,7 +42,7 @@ export const taskListExtensions = [
})
]
export const headingLevels: Level[] = [1, 2, 3, 4, 5, 6]
export const supportedHeadingLevels: Level[] = [1, 2]
export const defaultExtensions: AnyExtension[] = [
StarterKit.configure({
@ -60,7 +60,7 @@ export const defaultExtensions: AnyExtension[] = [
}
},
heading: {
levels: headingLevels
levels: supportedHeadingLevels
}
}),
Highlight.configure({

View File

@ -30,27 +30,15 @@ export interface RefInputActionItem extends Doc {
/**
* @public
*/
export const FORMAT_MODES = [
'bold',
'italic',
'strike',
'link',
'orderedList',
'bulletList',
'blockquote',
'code',
'codeBlock',
'heading',
'heading1',
'heading2',
'table',
'underline'
] as const
/**
* @public
*/
export type FormatMode = (typeof FORMAT_MODES)[number]
export enum TextFormatCategory {
Heading = 'heading',
TextDecoration = 'text-decoration', // bold, italic, strike, underline
Link = 'link',
List = 'list', // orderedList, bulletList,
Quote = 'quote', // blockquote
Code = 'code', // code, codeBlock
Table = 'table'
}
/**
* @public

View File

@ -2,9 +2,3 @@
cursor: pointer;
object-fit: contain;
}
// need to override editor's bubble max-width
// due to https://github.com/atomiks/tippyjs/issues/451
.tippy-box {
max-width: 450px !important;
}