diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a71fe6cc22..b34f7b55bf 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1076,6 +1076,9 @@ dependencies: simplytyped: specifier: ^3.3.0 version: 3.3.0(typescript@5.2.2) + slugify: + specifier: ^1.6.6 + version: 1.6.6 smartcrop: specifier: ~2.0.5 version: 2.0.5 @@ -15003,6 +15006,11 @@ packages: engines: {node: '>=8'} dev: false + /slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + dev: false + /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -22698,7 +22706,7 @@ packages: dev: false file:projects/text-editor.tgz(@types/node@16.11.68)(bufferutil@4.0.7)(esbuild@0.16.17)(postcss-load-config@4.0.1)(postcss@8.4.31)(ts-node@10.9.1): - resolution: {integrity: sha512-J3QznelWi3nYzEv3ijunCv6XZll2NhN+mPlU92LVs7uY5zImg+5g7hvjygdc9J/WJp3iYoljomiKP0y8iiTpeA==, tarball: file:projects/text-editor.tgz} + resolution: {integrity: sha512-OzXa/nYb9KYZcR07KJ2MZdeQEKBuy5eV3AO4OPDsvlZZpy7o6Tl3osEImE4kiRgT31bnLs39SD8KNzM1EJhU6g==, tarball: file:projects/text-editor.tgz} id: file:projects/text-editor.tgz name: '@rush-temp/text-editor' version: 0.0.0 @@ -22757,6 +22765,7 @@ packages: prosemirror-view: 1.32.0 rfc6902: 5.0.1 sass: 1.69.0 + slugify: 1.6.6 svelte: 3.55.1 svelte-check: 3.5.2(postcss-load-config@4.0.1)(postcss@8.4.31)(sass@1.69.0)(svelte@3.55.1) svelte-loader: 3.1.9(svelte@3.55.1) diff --git a/packages/text-editor/lang/en.json b/packages/text-editor/lang/en.json index bd32d6946e..86caa10c6e 100644 --- a/packages/text-editor/lang/en.json +++ b/packages/text-editor/lang/en.json @@ -1,5 +1,6 @@ { "string": { + "TableOfContents": "Table of contents", "Suggested": "SUGGESTED", "NoItems": "No items", "EditorPlaceholder": "Start typing...", diff --git a/packages/text-editor/lang/ru.json b/packages/text-editor/lang/ru.json index ae1f0e5a9f..b08ddbe178 100644 --- a/packages/text-editor/lang/ru.json +++ b/packages/text-editor/lang/ru.json @@ -1,5 +1,6 @@ { "string": { + "TableOfContents": "Оглавление", "Suggested": "РЕКОМЕНДУЕМЫЕ", "NoItems": "Нет содержимого", "EditorPlaceholder": "Начните печатать...", diff --git a/packages/text-editor/package.json b/packages/text-editor/package.json index b86c750d88..cc6b6956d4 100644 --- a/packages/text-editor/package.json +++ b/packages/text-editor/package.json @@ -83,6 +83,7 @@ "@tiptap/extension-bubble-menu": "^2.1.12", "@tiptap/extension-underline": "^2.1.12", "@hocuspocus/provider": "^2.5.0", - "@tiptap/extension-list-keymap": "^2.1.12" + "@tiptap/extension-list-keymap": "^2.1.12", + "slugify": "^1.6.6" } } diff --git a/packages/text-editor/src/components/extension/headings.ts b/packages/text-editor/src/components/extension/headings.ts new file mode 100644 index 0000000000..1c6a5d36e8 --- /dev/null +++ b/packages/text-editor/src/components/extension/headings.ts @@ -0,0 +1,170 @@ +// +// Copyright © 2023 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Extension } from '@tiptap/core' +import { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import slugify from 'slugify' +import { Heading } from '../../types' + +export interface HeadingsOptions { + prefixId?: string + onChange?: (headings: Heading[]) => void +} + +export interface HeadingsStorage { + headings: Heading[] +} + +export const HeadingsExtension: Extension = Extension.create({ + name: 'headings-extension', + + addStorage () { + return { + headings: [] + } + }, + + onDestroy () { + this.storage.headings = [] + this.options.onChange?.(this.storage.headings) + }, + + addProseMirrorPlugins () { + const options = this.options + const storage = this.storage + + const prefixId = options.prefixId ?? '' + + const plugins = [ + new Plugin({ + key: new PluginKey('heading-id-decoration-plugin'), + state: { + init (config, state) { + const decorations = getHeadingDecorations(state, prefixId) + const headings = extractHeadingsFromDecorations(decorations) + + options.onChange?.(headings) + + return { decorations } + }, + + apply (tr, value, oldState, newState) { + if (!tr.docChanged) { + return value + } + + let headingUpdate = false + + tr.mapping.maps.forEach((map, index) => + map.forEach((oldStart, oldEnd, newStart, newEnd) => { + const oldDoc = tr.docs[index] + const newDoc = tr.docs[index + 1] ?? tr.doc + + oldDoc.nodesBetween(oldStart, oldEnd, (node) => { + if (headingUpdate) { + return false + } else if (node.type.name === 'heading') { + headingUpdate = true + } + return true + }) + newDoc.nodesBetween(newStart, newEnd, (node) => { + if (headingUpdate) { + return false + } else if (node.type.name === 'heading') { + headingUpdate = true + } + return true + }) + }) + ) + + if (!headingUpdate) { + return value + } + + const decorations = getHeadingDecorations(newState, prefixId) + const headings = extractHeadingsFromDecorations(decorations) + + options.onChange?.(headings) + storage.headings = headings + + return { decorations } + } + }, + props: { + decorations (state) { + const pluginState = this.getState(state) + if (pluginState !== undefined) { + const { decorations } = pluginState + return DecorationSet.create(state.doc, decorations) + } + + return DecorationSet.empty + } + } + }) + ] + + return plugins + } +}) + +function getHeadingDecorations (state: EditorState, idPrefix: string): Decoration[] { + const decorations: Decoration[] = [] + const alreadySeen: Map = new Map() + + const { doc } = state + doc.descendants((node, pos) => { + if (node.type.name === 'heading') { + const id = getHeadingId(node, idPrefix, alreadySeen) + const title = node.textContent + const level = node.attrs.level + + if (title !== '') { + const heading: Heading = { id, title, level } + decorations.push(Decoration.node(pos, pos + node.nodeSize, { id }, { heading })) + } + } + }) + + return decorations +} + +function extractHeadingsFromDecorations (decorations: Decoration[]): Heading[] { + return decorations.map((it) => it.spec.heading).filter((it) => it !== undefined) +} + +function getHeadingId (node: ProseMirrorNode, prefix: string, ids: Map): string { + const name = prefix !== '' ? `${prefix}-${node.textContent}` : node.textContent + const id: string = slugify(name, { lower: true }) + + let uniqueId = id + let index = 0 + + while (ids.has(uniqueId)) { + index += 1 + uniqueId = `${id}-${index}` + } + + ids.set(id, index) + if (id !== uniqueId) { + ids.set(uniqueId, 0) + } + + return uniqueId +} diff --git a/packages/text-editor/src/components/toc/TableOfContents.svelte b/packages/text-editor/src/components/toc/TableOfContents.svelte new file mode 100644 index 0000000000..3df825baca --- /dev/null +++ b/packages/text-editor/src/components/toc/TableOfContents.svelte @@ -0,0 +1,103 @@ + + + +
+ +
+ {#each items as item} + {@const width = getLevelWidth(item.level)} +
+ {/each} +
+
+ + diff --git a/packages/text-editor/src/components/toc/TableOfContentsPopup.svelte b/packages/text-editor/src/components/toc/TableOfContentsPopup.svelte new file mode 100644 index 0000000000..69ea5e1152 --- /dev/null +++ b/packages/text-editor/src/components/toc/TableOfContentsPopup.svelte @@ -0,0 +1,63 @@ + + + + + +
dispatch('changeContent')}> +
+ + +
+ + {#each items as item} + {@const level = getIndentLevel(item.level)} + + {/each} + + + + diff --git a/packages/text-editor/src/index.ts b/packages/text-editor/src/index.ts index b2cbbfe0c3..8c65298926 100644 --- a/packages/text-editor/src/index.ts +++ b/packages/text-editor/src/index.ts @@ -29,10 +29,12 @@ export { default as StyledTextEditor } from './components/StyledTextEditor.svelt export { default as TextEditor } from './components/TextEditor.svelte' export { default as TextEditorStyleToolbar } from './components/TextEditorStyleToolbar.svelte' export { default as AttachIcon } from './components/icons/Attach.svelte' +export { default as TableOfContents } from './components/toc/TableOfContents.svelte' export { default } from './plugin' export * from './types' export * from './utils' +export { HeadingsExtension, type HeadingsOptions, type HeadingsStorage } from './components/extension/headings' export { IsEmptyContentExtension, type IsEmptyContentOptions, diff --git a/packages/text-editor/src/plugin.ts b/packages/text-editor/src/plugin.ts index 5aa6f62824..7a8bb16b9c 100644 --- a/packages/text-editor/src/plugin.ts +++ b/packages/text-editor/src/plugin.ts @@ -28,6 +28,7 @@ export default plugin(textEditorId, { RefInputActionItem: '' as Ref> }, string: { + TableOfContents: '' as IntlString, Suggested: '' as IntlString, NoItems: '' as IntlString, Attach: '' as IntlString, diff --git a/packages/text-editor/src/types.ts b/packages/text-editor/src/types.ts index 62db5cd748..f20b0cda49 100644 --- a/packages/text-editor/src/types.ts +++ b/packages/text-editor/src/types.ts @@ -63,3 +63,12 @@ export interface TextNodeAction { label?: IntlString icon: Asset | AnySvelteComponent } + +/** + * @public + */ +export interface Heading { + id: string + level: number + title: string +} diff --git a/packages/theme/styles/_colors.scss b/packages/theme/styles/_colors.scss index fcefac9b8b..d213fdc1b8 100644 --- a/packages/theme/styles/_colors.scss +++ b/packages/theme/styles/_colors.scss @@ -282,6 +282,9 @@ --trans-content-05: rgba(138, 143, 152, .05); --trans-content-10: rgba(138, 143, 152, .1); --trans-content-20: rgba(138, 143, 152, .2); + + --text-editor-toc-default-color: rgba(255, 255, 255, 0.1); + --text-editor-toc-hovered-color: rgba(255, 255, 255, 0.4); } /* Light Theme */ @@ -488,4 +491,7 @@ --trans-content-05: rgba(60, 65, 73, .05); --trans-content-10: rgba(60, 65, 73, .1); --trans-content-20: rgba(60, 65, 73, .2); + + --text-editor-toc-default-color: rgba(0, 0, 0, 0.1); + --text-editor-toc-hovered-color: rgba(0, 0, 0, 0.4); } diff --git a/packages/theme/styles/_text-editor.scss b/packages/theme/styles/_text-editor.scss index df22e43d14..91482611c2 100644 --- a/packages/theme/styles/_text-editor.scss +++ b/packages/theme/styles/_text-editor.scss @@ -19,6 +19,14 @@ font-weight: 600; } + h1, + h2, + h3 { + &:first-child { + margin-top: 0; + } + } + .title { font-size: 2.25rem; margin-top: 3.75rem;