UBER-1076 Wiki images support (#3864)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2023-10-22 21:55:39 +07:00 committed by GitHub
parent e3e2b02d40
commit 435d094c7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 470 additions and 193 deletions

View File

@ -26,6 +26,7 @@
export let contentType: string | undefined
export let popupOptions: PopupOptions
export let value: Doc
export let showIcon = true
const dispatch = createEventDispatcher()
// let imgView: 'img-horizontal-fit' | 'img-vertical-fit' | 'img-original-fit' = 'img-vertical-fit'
@ -51,11 +52,13 @@
>
<svelte:fragment slot="title">
<div class="antiTitle icon-wrapper">
<div class="wrapped-icon">
<div class="flex-center icon">
{iconLabel(name)}
{#if showIcon}
<div class="wrapped-icon">
<div class="flex-center icon">
{iconLabel(name)}
</div>
</div>
</div>
{/if}
<span class="wrapped-title">{name}</span>
</div>
</svelte:fragment>

View File

@ -21,6 +21,12 @@
"GIF": "GIF",
"Mention": "Mention",
"Underlined": "Underlined",
"AlignCenter": "Align center",
"AlignLeft": "Align left",
"AlignRight": "Align right",
"ViewImage": "View image",
"ViewOriginal": "View original",
"MoreActions": "More actions",
"FullDescription": "Full description",
"NoFullDescription": "There are no detailed description",
"EnableDiffMode": "Diff mode",

View File

@ -21,6 +21,12 @@
"GIF": "GIF",
"Mention": "Упомянуть",
"Underlined": "Подчеркнутый",
"AlignCenter": "По центру",
"AlignLeft": "По левому краю",
"AlignRight": "По правому краю",
"ViewImage": "Открыть изображение",
"ViewOriginal": "Открыть оригинал",
"MoreActions": "Дополнительные действия",
"FullDescription": "Детальное описание",
"NoFullDescription": "Нет детального описания",
"EnableDiffMode": "Режим сравнения",

View File

@ -2,6 +2,7 @@ import { Node, mergeAttributes } from '@tiptap/core'
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { getDataAttribute } from './utils'
export interface CompletionOptions {
HTMLAttributes: Record<string, any>
@ -96,49 +97,9 @@ export const Completion = Node.create<CompletionOptions>({
addAttributes () {
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute('data-id'),
renderHTML: (attributes) => {
// eslint-disable-next-line
if (!attributes.id) {
return {}
}
return {
'data-id': attributes.id
}
}
},
label: {
default: null,
parseHTML: (element) => element.getAttribute('data-label'),
renderHTML: (attributes) => {
// eslint-disable-next-line
if (!attributes.label) {
return {}
}
return {
'data-label': attributes.label
}
}
},
objectclass: {
default: null,
parseHTML: (element) => element.getAttribute('data-objectclass'),
renderHTML: (attributes) => {
// eslint-disable-next-line
if (!attributes.objectclass) {
return {}
}
return {
'data-objectclass': attributes.objectclass
}
}
}
id: getDataAttribute('id', null),
label: getDataAttribute('label', null),
objectclass: getDataAttribute('objectclass', null)
}
},

View File

@ -45,8 +45,11 @@
import { noSelectionRender } from './editor/collaboration'
import { defaultEditorAttributes } from './editor/editorProps'
import { completionConfig, defaultExtensions } from './extensions'
import { InlinePopupExtension } from './extension/inlinePopup'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import { FileAttachFunction, ImageExtension } from './extension/imageExt'
import { NodeUuidExtension } from './extension/nodeUuid'
import ImageStyleToolbar from './ImageStyleToolbar.svelte'
import StyleButton from './StyleButton.svelte'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
@ -72,6 +75,8 @@
export let onExtensions: () => AnyExtension[] = () => []
export let boundary: HTMLElement | undefined = undefined
export let attachFile: FileAttachFunction | undefined = undefined
let element: HTMLElement
const ydoc = (getContext(CollaborationIds.Doc) as Y.Doc | undefined) ?? new Y.Doc()
@ -99,7 +104,8 @@
const currentUser = getCurrentAccount()
let editor: Editor
let inlineToolbar: HTMLElement
let textToolbarElement: HTMLElement
let imageToolbarElement: HTMLElement
let placeHolderStr: string = ''
@ -234,6 +240,34 @@
$: updateEditor(editor, field, comparedVersion)
$: if (editor) dispatch('editor', editor)
const tippyOptions = {
zIndex: 100000,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary,
padding: 8,
altAxis: true,
tether: false
}
}
]
}
}
const optionalExtensions: AnyExtension[] = []
if (attachFile !== undefined) {
optionalExtensions.push(
ImageExtension.configure({
inline: true,
attachFile
})
)
}
onMount(() => {
ph.then(() => {
editor = new Editor({
@ -242,27 +276,25 @@
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) },
extensions: [
...defaultExtensions,
...optionalExtensions,
Placeholder.configure({ placeholder: placeHolderStr }),
InlineStyleToolbarExtension.configure({
tippyOptions: {
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary,
padding: 8,
altAxis: true,
tether: false
}
}
]
}
},
element: inlineToolbar,
tippyOptions,
element: textToolbarElement,
isSupported: () => !readonly,
isSelectionOnly: () => false
}),
InlinePopupExtension.configure({
pluginKey: 'show-image-actions-popup',
element: imageToolbarElement,
tippyOptions,
shouldShow: () => {
if (!visible && !readonly) {
return false
}
return editor?.isActive('image')
}
}),
Collaboration.configure({
document: ydoc,
field
@ -331,7 +363,7 @@
const { idx, focusManager } = registerFocus(focusIndex, {
focus: () => {
if (visible) {
focus('start')
focus()
}
return visible && element !== null
},
@ -346,6 +378,10 @@
$: if (element) {
element.addEventListener('focus', updateFocus, { once: true })
}
function handleFocus () {
needFocus = true
}
</script>
<slot {editor} />
@ -375,7 +411,7 @@
</div>
{/if}
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={inlineToolbar}>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={textToolbarElement}>
<TextEditorStyleToolbar
textEditor={editor}
textFormatCategories={[
@ -399,6 +435,10 @@
/>
</div>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={imageToolbarElement}>
<ImageStyleToolbar textEditor={editor} formatButtonSize={buttonSize} on:focus={handleFocus} />
</div>
<div class="ref-container" style:overflow>
<div class="text-input" class:focusable>
<div class="select-text" style="width: 100%;" bind:this={element} />

View File

@ -0,0 +1,133 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { PDFViewer, getFileUrl } from '@hcengineering/presentation'
import { IconExpand, IconMoreH, IconSize, SelectPopup, getEventPositionElement, showPopup } from '@hcengineering/ui'
import { Editor } from '@tiptap/core'
import IconAlignCenter from './icons/AlignCenter.svelte'
import IconAlignLeft from './icons/AlignLeft.svelte'
import IconAlignRight from './icons/AlignRight.svelte'
import IconScaleOut from './icons/ScaleOut.svelte'
import StyleButton from './StyleButton.svelte'
import plugin from '../plugin'
export let formatButtonSize: IconSize = 'small'
export let textEditor: Editor
const dispatch = createEventDispatcher()
function getImageAlignmentToggler (align: 'center' | 'left' | 'right') {
return () => {
textEditor.commands.setImageAlignment({ align })
dispatch('focus')
}
}
function openImage () {
const attributes = textEditor.getAttributes('image')
const fileId = attributes['file-id']
const fileName = attributes.alt ?? ''
showPopup(PDFViewer, { file: fileId, name: fileName, contentType: 'image/*', showIcon: false }, 'centered', () => {
dispatch('focus')
})
}
function openOriginalImage () {
const attributes = textEditor.getAttributes('image')
const url = getFileUrl(attributes['file-id'], 'full')
window.open(url, '_blank')
}
function moreOptions (event: MouseEvent) {
const widthActions = ['25%', '50%', '75%', '100%', plugin.string.Unset].map((it) => {
return {
id: `#imageWidth${it}`,
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it),
action: () => textEditor.commands.setImageSize({ width: it === plugin.string.Unset ? undefined : it }),
category: {
label: plugin.string.Width
}
}
})
const actions = [
{
id: '#imageOpen',
icon: IconScaleOut,
label: plugin.string.ViewImage,
action: openImage
},
{
id: '#imageOriginal',
icon: IconExpand,
label: plugin.string.ViewOriginal,
action: openOriginalImage
},
...widthActions
]
showPopup(
SelectPopup,
{
value: actions
},
getEventPositionElement(event),
(val) => {
if (val !== undefined) {
const op = actions.find((it) => it.id === val)
if (op) {
op.action()
dispatch('focus')
}
}
}
)
}
</script>
{#if textEditor}
{#if textEditor.isActive('image')}
<StyleButton
icon={IconAlignLeft}
size={formatButtonSize}
selected={textEditor.isActive('image', { align: 'left' })}
showTooltip={{ label: plugin.string.AlignLeft }}
on:click={getImageAlignmentToggler('left')}
/>
<StyleButton
icon={IconAlignCenter}
size={formatButtonSize}
selected={textEditor.isActive('image', { align: 'center' })}
showTooltip={{ label: plugin.string.AlignCenter }}
on:click={getImageAlignmentToggler('center')}
/>
<StyleButton
icon={IconAlignRight}
size={formatButtonSize}
selected={textEditor.isActive('image', { align: 'right' })}
showTooltip={{ label: plugin.string.AlignRight }}
on:click={getImageAlignmentToggler('right')}
/>
<div class="buttons-divider" />
<StyleButton
icon={IconMoreH}
size={formatButtonSize}
on:click={moreOptions}
showTooltip={{ label: plugin.string.MoreActions }}
/>
{/if}
{/if}

View File

@ -15,6 +15,7 @@
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import type { AnyExtension } from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Completion } from '../Completion'
import textEditorPlugin from '../plugin'
@ -23,9 +24,7 @@
import { completionConfig } from './extensions'
import { EmojiExtension } from './extension/emoji'
import { FocusExtension } from './extension/focus'
import { ImageRef, FileAttachFunction } from './imageExt'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { ImageExtension, FileAttachFunction } from './extension/imageExt'
import { RefAction } from '../types'
export let label: IntlString | undefined = undefined
@ -170,7 +169,7 @@
}
function configureExtensions () {
const imagePlugin = ImageRef.configure({
const imagePlugin = ImageExtension.configure({
inline: true,
HTMLAttributes: {},
attachFile,

View File

@ -1,6 +1,6 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// Copyright © 2021, 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@ -15,21 +15,23 @@
-->
<script lang="ts">
import { IntlString, translate } from '@hcengineering/platform'
import { themeStore } from '@hcengineering/ui'
import { FocusPosition, mergeAttributes } from '@tiptap/core'
import { AnyExtension, Editor, Extension, HTMLContent } from '@tiptap/core'
import Placeholder from '@tiptap/extension-placeholder'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import textEditorPlugin from '../plugin'
import { defaultExtensions } from './extensions'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { themeStore } from '@hcengineering/ui'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import textEditorPlugin from '../plugin'
import { TextFormatCategory } from '../types'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import { defaultEditorAttributes } from './editor/editorProps'
import { defaultExtensions } from './extensions'
import { InlinePopupExtension } from './extension/inlinePopup'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import ImageStyleToolbar from './ImageStyleToolbar.svelte'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
export let content: string = ''
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
@ -78,7 +80,25 @@
let needFocus = false
let focused = false
let posFocus: FocusPosition | undefined = undefined
let textEditorToolbar: HTMLElement
let textToolbarElement: HTMLElement
let imageToolbarElement: HTMLElement
const tippyOptions = {
zIndex: 100000,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary,
padding: 8,
altAxis: true,
tether: false
}
}
]
}
}
export function focus (position?: FocusPosition): void {
posFocus = position
@ -138,25 +158,16 @@
Placeholder.configure({ placeholder: placeHolderStr }),
...extensions,
InlineStyleToolbarExtension.configure({
tippyOptions: {
zIndex: 100000,
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
boundary,
padding: 8,
altAxis: true,
tether: false
}
}
]
}
},
element: textEditorToolbar,
tippyOptions,
element: textToolbarElement,
isSupported: () => true,
isSelectionOnly: () => false
}),
InlinePopupExtension.configure({
pluginKey: 'show-image-actions-popup',
element: imageToolbarElement,
tippyOptions,
shouldShow: () => editor?.isActive('image')
})
],
parseOptions: {
@ -206,7 +217,7 @@
}
</script>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={textEditorToolbar}>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={textToolbarElement}>
<TextEditorStyleToolbar
textEditor={editor}
{textFormatCategories}
@ -215,6 +226,16 @@
}}
/>
</div>
<div class="formatPanel buttons-group xsmall-gap mb-4" bind:this={imageToolbarElement}>
<ImageStyleToolbar
textEditor={editor}
on:focus={() => {
needFocus = true
}}
/>
</div>
<div class="select-text" style="width: 100%;" bind:this={element} />
<style lang="scss">

View File

@ -1,12 +1,11 @@
import { getEmbeddedLabel, getMetadata } from '@hcengineering/platform'
import { getMetadata } from '@hcengineering/platform'
import presentation, { getFileUrl } from '@hcengineering/presentation'
import { Action, IconSize, Menu, getEventPositionElement, getIconSize2x, showPopup } from '@hcengineering/ui'
import { IconSize, getIconSize2x } from '@hcengineering/ui'
import { Node, createNodeFromContent, mergeAttributes, nodeInputRule } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import plugin from '../plugin'
import { Fragment, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { EditorView } from 'prosemirror-view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { EditorView } from '@tiptap/pm/view'
import { getDataAttribute } from '../../utils'
/**
* @public
@ -25,6 +24,15 @@ export interface ImageOptions {
reportNode?: (id: string, node: ProseMirrorNode) => void
}
export interface ImageAlignmentOptions {
align?: 'center' | 'left' | 'right'
}
export interface ImageSizeOptions {
height?: number | string
width?: number | string
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
image: {
@ -32,6 +40,14 @@ declare module '@tiptap/core' {
* Add an image
*/
setImage: (options: { src: string, alt?: string, title?: string }) => ReturnType
/**
* Set image alignment
*/
setImageAlignment: (options: ImageAlignmentOptions) => ReturnType
/**
* Set image size
*/
setImageSize: (options: ImageSizeOptions) => ReturnType
}
}
}
@ -52,7 +68,7 @@ function getType (type: string): 'image' | 'other' {
/**
* @public
*/
export const ImageRef = Node.create<ImageOptions>({
export const ImageExtension = Node.create<ImageOptions>({
name: 'image',
addOptions () {
@ -71,6 +87,7 @@ export const ImageRef = Node.create<ImageOptions>({
},
draggable: true,
selectable: true,
addAttributes () {
@ -92,7 +109,8 @@ export const ImageRef = Node.create<ImageOptions>({
},
title: {
default: null
}
},
align: getDataAttribute('align')
}
},
@ -100,23 +118,33 @@ export const ImageRef = Node.create<ImageOptions>({
return [
{
tag: `img[data-type="${this.name}"]`
},
{
tag: 'img[src]'
}
]
},
renderHTML ({ node, HTMLAttributes }) {
const merged = mergeAttributes(
const divAttributes = {
class: 'text-editor-image-container',
'data-type': this.name,
'data-align': node.attrs.align ?? 'center'
}
const imgAttributes = mergeAttributes(
{
'data-type': this.name
},
this.options.HTMLAttributes,
HTMLAttributes
)
const id = merged['file-id']
const id = imgAttributes['file-id']
if (id != null) {
merged.src = getFileUrl(id, 'full')
imgAttributes.src = getFileUrl(id, 'full')
let width: IconSize | undefined
switch (merged.width) {
switch (imgAttributes.width) {
case '32px':
width = 'small'
break
@ -132,13 +160,15 @@ export const ImageRef = Node.create<ImageOptions>({
break
}
if (width !== undefined) {
merged.src = getFileUrl(id, width)
merged.srcset = getFileUrl(id, width) + ' 1x,' + getFileUrl(id, getIconSize2x(width)) + ' 2x'
imgAttributes.src = getFileUrl(id, width)
imgAttributes.srcset = getFileUrl(id, width) + ' 1x,' + getFileUrl(id, getIconSize2x(width)) + ' 2x'
}
merged.class = 'text-editor-image'
imgAttributes.class = 'text-editor-image'
imgAttributes.contentEditable = false
this.options.reportNode?.(id, node)
}
return ['img', merged]
return ['div', divAttributes, ['img', imgAttributes]]
},
addCommands () {
@ -150,6 +180,26 @@ export const ImageRef = Node.create<ImageOptions>({
type: this.name,
attrs: options
})
},
setImageAlignment:
(options) =>
({ chain, tr }) => {
const { from } = tr.selection
return chain()
.updateAttributes(this.name, { ...options })
.setNodeSelection(from)
.run()
},
setImageSize:
(options) =>
({ chain, tr }) => {
const { from } = tr.selection
return chain()
.updateAttributes(this.name, { ...options })
.setNodeSelection(from)
.run()
}
}
},
@ -167,6 +217,7 @@ export const ImageRef = Node.create<ImageOptions>({
})
]
},
addProseMirrorPlugins () {
const opt = this.options
function handleDrop (
@ -246,7 +297,7 @@ export const ImageRef = Node.create<ImageOptions>({
new Plugin({
key: new PluginKey('handle-image-paste'),
props: {
handlePaste (view, event, slice) {
handlePaste (view, event) {
const dataTransfer = event.clipboardData
if (dataTransfer !== null) {
const res = handleDrop(view, { pos: view.state.selection.$from.pos, inside: 0 }, dataTransfer)
@ -257,90 +308,13 @@ export const ImageRef = Node.create<ImageOptions>({
return res
}
},
handleDrop (view, event, slice) {
handleDrop (view, event) {
event.preventDefault()
event.stopPropagation()
const dataTransfer = event.dataTransfer
if (dataTransfer !== null) {
return handleDrop(view, view.posAtCoords({ left: event.x, top: event.y }), dataTransfer)
}
},
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false
}
const node = event.target as unknown as HTMLElement
if (node != null) {
const fileId = (node as any).attributes['file-id']?.value
if (fileId === undefined) {
return false
}
const pos = view.posAtDOM(node, 0)
const actions: Action[] = [
{
label: plugin.string.Width,
action: async (props, event) => {},
component: Menu,
props: {
actions: [
'32px',
'64px',
'128px',
'256px',
'512px',
'25%',
'50%',
'75%',
'100%',
plugin.string.Unset
].map((it) => {
return {
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it),
action: async () => {
view.dispatch(
view.state.tr.setNodeAttribute(pos, 'width', it === plugin.string.Unset ? null : it)
)
}
}
})
}
},
{
label: plugin.string.Height,
action: async (props, event) => {},
component: Menu,
props: {
actions: [
'32px',
'64px',
'128px',
'256px',
'512px',
'25%',
'50%',
'75%',
'100%',
plugin.string.Unset
].map((it) => {
return {
label: it === plugin.string.Unset ? it : getEmbeddedLabel(it),
action: async () => {
view.dispatch(
view.state.tr.setNodeAttribute(pos, 'height', it === plugin.string.Unset ? null : it)
)
}
}
})
}
}
]
event.preventDefault()
event.stopPropagation()
showPopup(Menu, { actions }, getEventPositionElement(event))
}
return false
}
}
})

View File

@ -101,6 +101,10 @@ export const InlineStyleToolbarExtension = Extension.create<InlineStyleToolbarOp
return false
}
if (editor.isActive('image')) {
return false
}
return true
}
})

View File

@ -0,0 +1,13 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 5C15 4.44772 15.4477 4 16 4C16.5523 4 17 4.44772 17 5V27C17 27.5523 16.5523 28 16 28C15.4477 28 15 27.5523 15 27V24H7C6.44772 24 6 23.5523 6 23C6 22.4477 6.44772 22 7 22H15V17H9C8.44772 17 8 16.5523 8 16C8 15.4477 8.44772 15 9 15H15V10H5C4.44772 10 4 9.55228 4 9C4 8.44772 4.44772 8 5 8H15V5Z"
/>
<path d="M19 24H25C25.5523 24 26 23.5523 26 23C26 22.4477 25.5523 22 25 22H19V24Z" />
<path d="M19 17H22C22.5523 17 23 16.5523 23 16C23 15.4477 22.5523 15 22 15H19V17Z" />
<path d="M19 10H27C27.5523 10 28 9.55228 28 9C28 8.44772 27.5523 8 27 8H19V10Z" />
</svg>

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
d="M5 4C4.44772 4 4 4.44772 4 5V27C4 27.5523 4.44772 28 5 28C5.55228 28 6 27.5523 6 27V5C6 4.44772 5.55228 4 5 4Z"
/>
<path
d="M27 8C27.5523 8 28 8.44772 28 9C28 9.55228 27.5523 10 27 10H11C10.4477 10 10 9.55228 10 9C10 8.44772 10.4477 8 11 8H27Z"
/>
<path
d="M22 15C22.5523 15 23 15.4477 23 16C23 16.5523 22.5523 17 22 17H11C10.4477 17 10 16.5523 10 16C10 15.4477 10.4477 15 11 15H22Z"
/>
<path
d="M28 23C28 22.4477 27.5523 22 27 22H11C10.4477 22 10 22.4477 10 23C10 23.5523 10.4477 24 11 24H27C27.5523 24 28 23.5523 28 23Z"
/>
</svg>

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
d="M27 4C26.4477 4 26 4.44772 26 5V27C26 27.5523 26.4477 28 27 28C27.5523 28 28 27.5523 28 27V5C28 4.44772 27.5523 4 27 4Z"
/>
<path
d="M5 8C4.44772 8 4 8.44772 4 9C4 9.55228 4.44772 10 5 10H21C21.5523 10 22 9.55228 22 9C22 8.44772 21.5523 8 21 8H5Z"
/>
<path
d="M10 15C9.44772 15 9 15.4477 9 16C9 16.5523 9.44772 17 10 17H21C21.5523 17 22 16.5523 22 16C22 15.4477 21.5523 15 21 15H10Z"
/>
<path
d="M4 23C4 22.4477 4.44772 22 5 22H21C21.5523 22 22 22.4477 22 23C22 23.5523 21.5523 24 21 24H5C4.44772 24 4 23.5523 4 23Z"
/>
</svg>

View File

@ -0,0 +1,19 @@
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
d="M20 4V6H24.586L18.293 12.2929C17.9024 12.6834 17.9024 13.3166 18.293 13.7071C18.6835 14.0976 19.3167 14.0976 19.7072 13.7071L26 7.414V12H28V4H20Z"
/>
<path
d="M13.7073 19.7071C14.0978 19.3166 14.0978 18.6834 13.7073 18.2929C13.3167 17.9024 12.6836 17.9024 12.293 18.2929L6 24.586V20H4V28H12V26H7.414L13.7073 19.7071Z"
/>
<path
d="M12 4V6H7.414L13.707 12.2929C14.0976 12.6834 14.0976 13.3166 13.707 13.7071C13.3165 14.0976 12.6833 14.0976 12.2928 13.7071L6 7.414V12H4V4H12Z"
/>
<path
d="M18.2927 19.7071C17.9022 19.3166 17.9022 18.6834 18.2927 18.2929C18.6833 17.9024 19.3164 17.9024 19.707 18.2929L26 24.586V20H28V28H20V26H24.586L18.2927 19.7071Z"
/>
</svg>

View File

@ -55,5 +55,6 @@ export {
type InlineStyleToolbarOptions,
type InlineStyleToolbarStorage
} from './components/extension/inlineStyleToolbar'
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
export { textEditorId }

View File

@ -53,6 +53,13 @@ export default plugin(textEditorId, {
NoFullDescription: '' as IntlString,
EnableDiffMode: '' as IntlString,
AlignCenter: '' as IntlString,
AlignLeft: '' as IntlString,
AlignRight: '' as IntlString,
ViewImage: '' as IntlString,
ViewOriginal: '' as IntlString,
MoreActions: '' as IntlString,
InsertTable: '' as IntlString,
AddColumnBefore: '' as IntlString,
AddColumnAfter: '' as IntlString,

View File

@ -14,6 +14,7 @@
//
import { onStatelessParameters } from '@hocuspocus/provider'
import { Attribute } from '@tiptap/core'
import * as Y from 'yjs'
import { TiptapCollabProvider } from './provider'
@ -76,3 +77,22 @@ export function copyDocumentContent (
const provider = getProvider(documentId, providerData, initialContentId)
provider.copyContent(documentId, snapshotId)
}
export function getDataAttribute (name: string, def?: unknown | null): Partial<Attribute> {
const dataName = `data-${name}`
return {
default: def,
parseHTML: (element) => element.getAttribute(dataName),
renderHTML: (attributes) => {
// eslint-disable-next-line
if (!attributes[name]) {
return {}
}
return {
[dataName]: attributes[name]
}
}
}
}

View File

@ -73,6 +73,8 @@
--highlight-red-hover: #ff967e;
--highlight-red-press: #f96f50bd;
--text-editor-selected-node-color: #93CAF3;
--text-editor-highlighted-node-warning-active-background-color: #F2D7AE;
--text-editor-highlighted-node-warning-background-color: #F8EBD7;
--text-editor-highlighted-node-warning-border-color: #DE9B35;

View File

@ -169,11 +169,41 @@
}
}
.text-editor-image-container {
display: flex;
flex-direction: row;
&[data-align="center"] {
justify-content: center;
}
&[data-align="left"] {
justify-content: left;
}
&[data-align="right"] {
justify-content: right;
}
}
.text-editor-image-container {
img {
max-width: 100%;
}
}
.text-editor-image {
cursor: pointer;
object-fit: contain;
}
.ProseMirror-selectednode {
img {
box-shadow: 0 0 0 2px var(--text-editor-selected-node-color);
border-radius: 0.125rem;
}
}
.text-editor-highlighted-node-warning {
background-color: var(--text-editor-highlighted-node-warning-background-color);
border-bottom: 0.0625rem solid var(--text-editor-highlighted-node-warning-border-color);