UBER-1103 Document table of contents (#3875)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2023-10-24 22:39:56 +07:00 committed by GitHub
parent 2363aed133
commit b35034576e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 376 additions and 2 deletions

View File

@ -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)

View File

@ -1,5 +1,6 @@
{
"string": {
"TableOfContents": "Table of contents",
"Suggested": "SUGGESTED",
"NoItems": "No items",
"EditorPlaceholder": "Start typing...",

View File

@ -1,5 +1,6 @@
{
"string": {
"TableOfContents": "Оглавление",
"Suggested": "РЕКОМЕНДУЕМЫЕ",
"NoItems": "Нет содержимого",
"EditorPlaceholder": "Начните печатать...",

View File

@ -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"
}
}

View File

@ -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<HeadingsOptions, HeadingsStorage> = Extension.create<HeadingsOptions>({
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<string, number> = new Map<string, number>()
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, number>): 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
}

View File

@ -0,0 +1,103 @@
<!--
//
// 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 { getPopupPositionElement, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import TableofContentsPopup from './TableOfContentsPopup.svelte'
import { Heading } from '../../types'
export let items: Heading[] = []
export let selected: Heading | undefined = undefined
const dispatch = createEventDispatcher()
$: minLevel = items.reduce((p, v) => Math.min(p, v.level), Infinity)
$: maxLevel = items.reduce((p, v) => Math.max(p, v.level), 0)
function getLevelWidth (level: number) {
return (100 * (maxLevel - level + 1)) / (maxLevel - minLevel + 1)
}
let hovered = false
function handleOpenToc (ev: MouseEvent) {
ev.preventDefault()
ev.stopPropagation()
hovered = true
showPopup(
TableofContentsPopup,
{ items, selected },
getPopupPositionElement(ev.target as HTMLElement, { v: 'top', h: 'right' }),
(res) => {
hovered = false
if (res) {
dispatch('select', res)
}
}
)
}
</script>
<div class="root">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="toc" class:hovered on:click={handleOpenToc}>
{#each items as item}
{@const width = getLevelWidth(item.level)}
<div class="toc-item" class:selected={item.id === selected?.id} style={`width: ${width}%;`} />
{/each}
</div>
</div>
<style lang="scss">
.root {
display: inline-flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 1.5rem;
}
.toc {
width: 0.75rem;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 0.625rem;
overflow: hidden;
.toc-item {
display: inline-block;
height: 0;
border: 1px solid var(--text-editor-toc-default-color);
&.selected {
border: 1px solid var(--text-editor-toc-hovered-color);
}
}
&:hover,
&.hovered {
cursor: pointer;
.toc-item {
border: 1px solid var(--text-editor-toc-hovered-color);
}
}
}
</style>

View File

@ -0,0 +1,63 @@
<!--
//
// 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 { FocusHandler, Label, Scroller, createFocusManager, resizeObserver } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import textEditorPlugin from '../../plugin'
import { Heading } from '../../types'
export let items: Heading[] = []
export let selected: Heading | undefined = undefined
$: minLevel = items.reduce((p, v) => Math.min(p, v.level), Infinity)
function getIndentLevel (level: number) {
return 1 * (level - minLevel)
}
const dispatch = createEventDispatcher()
const manager = createFocusManager()
</script>
<FocusHandler {manager} />
<div class="selectPopup" use:resizeObserver={() => dispatch('changeContent')}>
<div class="header ml-2">
<span class="fs-title overflow-label">
<Label label={textEditorPlugin.string.TableOfContents} />
</span>
</div>
<Scroller>
{#each items as item}
{@const level = getIndentLevel(item.level)}
<button class="menu-item no-focus flex-row-center" on:click={() => dispatch('close', item)}>
<div class="label overflow-label flex-grow" class:selected={item.id === selected?.id}>
<span style={`padding-left: ${level * 1.5}rem;`}>
{item.title}
</span>
</div>
</button>
{/each}
</Scroller>
<div class="menu-space" />
</div>
<style lang="scss">
.selected {
color: var(--theme-primary-default);
}
</style>

View File

@ -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,

View File

@ -28,6 +28,7 @@ export default plugin(textEditorId, {
RefInputActionItem: '' as Ref<Class<RefInputActionItem>>
},
string: {
TableOfContents: '' as IntlString,
Suggested: '' as IntlString,
NoItems: '' as IntlString,
Attach: '' as IntlString,

View File

@ -63,3 +63,12 @@ export interface TextNodeAction {
label?: IntlString
icon: Asset | AnySvelteComponent
}
/**
* @public
*/
export interface Heading {
id: string
level: number
title: string
}

View File

@ -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);
}

View File

@ -19,6 +19,14 @@
font-weight: 600;
}
h1,
h2,
h3 {
&:first-child {
margin-top: 0;
}
}
.title {
font-size: 2.25rem;
margin-top: 3.75rem;