Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/web-pkg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"lodash-es": "^4.17.23",
"lowlight": "^3.3.0",
"luxon": "^3.7.2",
"marked": "catalog:tiptap",
"oidc-client-ts": "^3.5.0",
"p-queue": "^9.1.0",
"password-sheriff": "^2.0.0",
Expand Down
13 changes: 11 additions & 2 deletions packages/web-pkg/src/editor/components/CodeBlockComponent.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<template>
<node-view-wrapper class="code-block">
<div class="text-editor-code-block-language" contenteditable="false">
<oc-icon name="code-box" size="xsmall" fill-type="line" />
<select
v-model="selectedLanguageValue"
class="text-editor-code-block-select font-mono"
class="text-editor-code-block-select"
:aria-label="$gettext('Code language')"
:disabled="isReadonly"
>
Expand Down Expand Up @@ -78,6 +79,9 @@ const isReadonly = computed(() => props.editor.isEditable === false)
position: absolute;
right: 12px;
top: 4px;
display: inline-flex;
align-items: center;
color: var(--oc-role-on-surface-variant);
}

.text-editor-code-block-select:focus {
Expand All @@ -87,10 +91,15 @@ const isReadonly = computed(() => props.editor.isEditable === false)

.text-editor-code-block-select {
text-align: right;
text-align-last: right;
field-sizing: content;
width: auto;
min-width: 0;
font-size: 12px;
font-family: inherit;
border: 0;
background: transparent;
color: var(--oc-role-on-surface);
color: inherit;
}

.text-editor-code-block-select:disabled {
Expand Down
50 changes: 50 additions & 0 deletions packages/web-pkg/src/editor/components/FrontmatterComponent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<template>
<node-view-wrapper class="text-editor-frontmatter">
<span class="text-editor-frontmatter-label" contenteditable="false">
<oc-icon name="file-list-2" size="xsmall" fill-type="line" />
{{ $gettext('Frontmatter') }}
</span>
<span :id="hintId" class="sr-only">
{{
$gettext(
'Press Shift+Enter or press Enter three times in a row to exit the frontmatter block.'
)
}}
</span>
<pre
class="text-editor-frontmatter-content font-mono"
role="textbox"
:aria-describedby="hintId"
aria-multiline="true"
><node-view-content /></pre>
</node-view-wrapper>
</template>

<script setup lang="ts">
import { NodeViewContent, nodeViewProps, NodeViewWrapper } from '@tiptap/vue-3'
import { useGettext } from 'vue3-gettext'
import { v4 as uuidV4 } from 'uuid'

defineProps(nodeViewProps)
const { $gettext } = useGettext()
const hintId = `frontmatter-hint-${uuidV4()}`
</script>

<style scoped>
.text-editor-frontmatter-label {
position: absolute;
right: 8px;
top: 4px;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 12px;
color: var(--oc-role-on-surface-variant);
user-select: none;
}

.text-editor-frontmatter-content {
white-space: pre-wrap;
word-break: break-word;
}
</style>
20 changes: 18 additions & 2 deletions packages/web-pkg/src/editor/components/TextEditorContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
:editor="textEditor.editor.value"
@node-change="onDragHandleNodeChange"
>
<div class="flex items-center gap-1 mr-1 mt-[0.125rem]">
<div
v-show="isDraggable"
class="drag-handle-controls flex items-center gap-1 mr-1 mt-[0.125rem]"
>
<oc-button
v-if="hasSlashCommands"
appearance="raw"
Expand Down Expand Up @@ -59,6 +62,7 @@ import {
} from 'vue'
import { EditorContent } from '@tiptap/vue-3'
import { DragHandle } from '@tiptap/extension-drag-handle-vue-3'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { useGettext } from 'vue3-gettext'
import { storeToRefs } from 'pinia'
import TextEditorTableBubbleMenu from './TextEditorTableBubbleMenu.vue'
Expand All @@ -82,10 +86,21 @@ const textEditor = editor || inject<TextEditorInstance>('textEditor')!
const sourceContent = ref('')
const sourceModeTextareaRef = useTemplateRef<HTMLTextAreaElement>('sourceModeTextarea')
const currentDragHandleNodePos = ref<number | null>(null)
const isFrontmatterNode = ref(false)
const isDarkTheme = computed(() => unref(currentTheme)?.isDark)
const hljsThemeStyleElement = ref<HTMLLinkElement | null>(null)

const isSourceMode = computed(() => unref(textEditor.state.sourceMode))

const isDraggable = computed(() => {
// Frontmatter is pinned to the top of the document and cannot be moved, so
// neither hover handle has anything to offer there.
if (unref(isFrontmatterNode)) {
return false
}

return true
})
const zoomFactor = computed(() => {
return `${(unref(textEditor.state.editorZoom) || 100) / 100}`
})
Expand All @@ -111,8 +126,9 @@ const onSourceInput = (event: Event) => {
}
}

const onDragHandleNodeChange = ({ pos }: { pos: number }) => {
const onDragHandleNodeChange = ({ node, pos }: { node: ProseMirrorNode | null; pos: number }) => {
currentDragHandleNodePos.value = pos
isFrontmatterNode.value = node?.type.name === 'frontmatter'
}

const openSlashMenu = () => {
Expand Down
81 changes: 75 additions & 6 deletions packages/web-pkg/src/editor/composables/strategies/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
import type { Extension, JSONContent } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import StarterKit from '@tiptap/starter-kit'
import Document from '@tiptap/extension-document'
import { Markdown, MarkdownManager } from '@tiptap/markdown'
import { Marked } from 'marked'
import type { marked as markedDefault } from 'marked'
import Image from '@tiptap/extension-image'
import FindAndReplace from '@tiptap/extension-find-and-replace'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { useGettext } from 'vue3-gettext'
import { EditorActionGroup, useEditorActions } from '../useEditorActions'
import { EditorAction, EditorActionGroup, useEditorActions } from '../useEditorActions'
import { TextEditorState } from '../../types'
import {
createCodeBlockLowlight,
createLinkExtension,
imageFileHandlerExtension
Frontmatter,
imageFileHandlerExtension,
registerFrontmatterTokenizer
} from '../../extensions'
import { ContentTypeStrategy, ExtensionsOptions } from './types'

export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeStrategy => {
const { $gettext } = useGettext()

// Every `MarkdownManager` appends its extensions' tokenizers to the marked
// instance it is given and never removes them. Left to the default, that is a
// process wide singleton which grows with every editor opened and keeps each
// manager it ever saw alive. Holding our own instance bounds both to the
// lifetime of this strategy.
// Cast because tiptap types the option as the default export, which carries a
// `getDefaults` helper a plain instance lacks. The manager never calls it: it
// only touches `use`, `setOptions`, `Lexer`, `lexer` and `defaults`.
const marked = new Marked() as unknown as typeof markedDefault
registerFrontmatterTokenizer(marked)

const editorContentType = () => {
return 'markdown'
}
Expand All @@ -30,7 +46,7 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
// never change for a given strategy.
let markdownManager: MarkdownManager | null = null
const serialize = (doc: ProseMirrorNode): string => {
markdownManager ??= new MarkdownManager({ extensions: extensions() })
markdownManager ??= new MarkdownManager({ marked, extensions: extensions() })
return markdownManager.serialize(doc.toJSON())
}

Expand Down Expand Up @@ -76,10 +92,18 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
StarterKit.configure({
link: false,
codeBlock: false,
document: false,
undoRedo: options?.yjs ? false : undefined
}),
// Frontmatter is metadata about the document, it only ever belongs at the
// top. Spelled as an alternation rather than `frontmatter? block+` so that
// a document holding nothing but metadata is valid too. `block+` has to
// come first: ProseMirror fills an empty document from the leading
// alternative, and that must be a paragraph rather than a metadata block.
Document.extend({ content: 'block+ | (frontmatter block*)' }),
Frontmatter,
createCodeBlockLowlight(),
Markdown,
Markdown.configure({ marked }),
createLinkExtension(),
Table.configure({ resizable: false }),
TableRow,
Expand Down Expand Up @@ -116,6 +140,7 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
horizontalRule,
link,
menuEmoji,
frontmatter,
image,
menuSearchAndReplace,
imageUrl,
Expand All @@ -131,8 +156,46 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
toggleHeaderRow,
deleteTable
} = useEditorActions(editorState)
/**
* Actions that still make sense while the caret sits in the frontmatter block.
* Everything else formats or inserts content, and the block holds raw metadata
* text that none of it applies to.
*
* This is an allowlist on purpose: an action added later is restricted until
* someone decides it belongs here.
*/
const actionsAllowedInFrontmatter = new Set([
'undo',
'redo',
'menu-zoom',
'zoom-in',
'zoom-out',
'zoom-reset',
'source-mode',
'menu-search-and-replace',
'print',
'frontmatter',
'menu-emoji'
])

const restrictInFrontmatter = (action: EditorAction): EditorAction => {
const childActions = action.childActions?.map(restrictInFrontmatter)

if (actionsAllowedInFrontmatter.has(action.id)) {
return childActions ? { ...action, childActions } : action
}

const { isEnabled } = action

return {
...action,
...(childActions && { childActions }),
isEnabled: (editor) => !editor.isActive('frontmatter') && (isEnabled?.(editor) ?? true)
}
}

const editorActionGroups = (): EditorActionGroup[] => {
return [
const groups: EditorActionGroup[] = [
{
id: 'navigation',
title: $gettext('Navigation'),
Expand Down Expand Up @@ -184,7 +247,8 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
deleteColumn(),
deleteTable(),
menuEmoji(),
horizontalRule()
horizontalRule(),
frontmatter()
]
},
{
Expand All @@ -198,6 +262,11 @@ export const useStrategyMarkdown = (editorState: TextEditorState): ContentTypeSt
actions: [print()]
}
]

return groups.map((group) => ({
...group,
actions: group.actions.map(restrictInFrontmatter)
}))
}

return {
Expand Down
43 changes: 43 additions & 0 deletions packages/web-pkg/src/editor/composables/useEditorActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,48 @@ export function useEditorActions(state: TextEditorState) {
isActive: () => false
})

// Frontmatter actions
const hasFrontmatter = (editor: Editor) => {
return editor.state.doc.firstChild?.type.name === 'frontmatter'
}

const toggleFrontmatter = (editor: Editor, range?: Range) => {
// The slash query is not content the user wants to keep, drop it either way.
if (range) {
editor.chain().focus().deleteRange(range).run()
}

if (!hasFrontmatter(editor)) {
editor.chain().focus().setFrontmatter().run()
return
}

// Only one block can exist, so there is nothing to add. Take the user to it
// instead, landing where another key would go.
if (!editor.isActive('frontmatter')) {
const endOfMetadata = editor.state.doc.firstChild!.nodeSize - 1
editor.chain().focus().setTextSelection(endOfMetadata).run()
return
}

// Unwrapping only drops the fences, the metadata stays in the document as
// content, so there is nothing to confirm.
editor.chain().focus().unsetFrontmatter().run()
}

const frontmatter = (): EditorAction => ({
id: 'frontmatter',
title: $gettext('Frontmatter'),
description: $gettext('Document metadata'),
icon: 'file-list-2',
iconFillType: 'line',
keywords: ['frontmatter', 'metadata', 'yaml'],
showInSlashCommands: false,
toolbarAction: (editor) => toggleFrontmatter(editor),
slashCommandAction: ({ editor, range }) => toggleFrontmatter(editor, range),
isActive: (editor) => editor.isActive('frontmatter')
})

// Table actions
const createTable = (): EditorAction => ({
id: 'table',
Expand Down Expand Up @@ -1045,6 +1087,7 @@ export function useEditorActions(state: TextEditorState) {
imageUpload,
imageCloud,
horizontalRule,
frontmatter,
// Table
createTable,
addRowBefore,
Expand Down
4 changes: 1 addition & 3 deletions packages/web-pkg/src/editor/extensions/codeBlockLowlight.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import { common, createLowlight } from 'lowlight'
import CodeBlockComponent from '../components/CodeBlockComponent.vue'

const lowlight = createLowlight(common)
import { lowlight } from './lowlight'

export function createCodeBlockLowlight() {
return CodeBlockLowlight.extend({
Expand Down
Loading