GrRichText
Overview
When to take it
- text the reader will see formatted — a description, an article, an announcement: paragraphs, lists and emphasis carry meaning, and losing them on saving is not allowed;
- a comment or a note with a minimal set —
schema="minimal": the style, a link, a list, and nothing that could spoil the page; - a form field rather than a screen of its own — the component reads
GrFormField, gives its value to the form through a hidden field and obeysdisabled,readonlyandinvalidon a par with the other controls; - a set of capabilities of your own —
extensionsadds TipTap extensions to the schema, and the component stays the same field with its own toolbar and keyboard.
When to take something else
| Need | Take |
|---|---|
| Plain text across several lines, with no markup | GrTextarea |
| A single-line string | GrInput |
| Show text in markdown rather than edit it | GrMarkdown |
| Show ready code with highlighting rather than edit text | GrCodeBlock |
| A set of tags of your own rather than text | GrInputTag |
Examples 3 each with a live demo and its code
The description of a listing
A listing needs structure, so the schema is
article: headings from h2 down, quotes and code on top of the basics, and no h1 in any schema — the page owns it. The field readsGrFormFieldlike any control — label,required,invalid— andnamesends the HTML with the form as a hidden field. Thefooterslot is a zone inside the frame, not a line under it.A reply to a ticket
The
minimalschema is also the sanitiser: what it has no node for — an image, a script, a table — does not survive a paste.toolbar="both"keeps the panel on top for the keyboard and adds a bubble at the selection for the mouse.headerandfooterhold the recipients and the send button inside the frame, and while the reply goes out the field isdisabled.Ticket #4182 · New address on invoices An update for the status page
output="json"hands the TipTap document out as is: the CMS keeps structure, not markup. The saved note is shown without an editor —markdownFromEditorDocumentturns the document into markdown andGrMarkdownreads it, with nothing lost from either schema. Once published the field isreadonly: read and copied, not edited.
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
size | GrRichTextSize | undefined | undefined | The size step: the type size of the field and the toolbar buttons. `md` by default; taken from `GrConfigProvider`. |
ariaLabel | string | undefined | undefined | The accessible name of the field; it overrides the label of `GrFormField`. |
modelValuev-model | string | Record<string, unknown> | null | undefined | undefined | The value: an HTML string or a TipTap document — by `output`. The shape is set by the prop, not by what the user does: the same decision as `valueAdapter` in `granularity-chrono`. |
output | "html" | "json" | undefined | "html" | In what shape the value goes outward. |
schema | GrRichTextSchemaName | undefined | "minimal" | A ready-made schema: `minimal` or `article`. |
extensions | GrRichTextExtension[] | undefined | undefined | Custom TipTap extensions **in addition** to the schema — mentions, images, a table. They do not try to replace the schema's set: the toolbar is built from it, and a substitution would leave the buttons without commands. |
placeholder | string | undefined | undefined | Hint in an empty field. |
toolbar | boolean | "bubble" | "both" | undefined | true | Where the formatting buttons live: a bar on top, a bubble menu by the selection, both, or nothing. |
id | string | undefined | undefined | Own `id` for the input area. Unset — taken from `GrFormField`. |
name | string | undefined | undefined | Name for the native form: the value goes out in a hidden field. |
disabled | boolean | undefined | false | The field is unavailable: the text cannot be edited, the toolbar buttons are disabled. Inherited from `GrFormField`. |
readonly | boolean | undefined | false | The value is visible and goes into the form, but cannot be edited. |
invalid | boolean | undefined | false | The visual and ARIA state of an error (`aria-invalid`). Inherited from `GrFormField`. |
required | boolean | undefined | false | A required field (`aria-required`). Inherited from `GrFormField`. |
Slots
| Slot | Type | Description |
|---|---|---|
header | any | A strip above the input area: a label, a counter, a mode switch. A separate zone rather than a place in the toolbar: the toolbar is built from the schema and moves focus with a rover, while here the content is the consumer's, with its own keyboard. |
footer | any | A strip below the input area: a character counter, a hint, submit buttons. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: string | Record<string, unknown>): void | — |
change | (event: "change", value: string | Record<string, unknown>): void | — |
focus | (event: "focus"): void | — |
blur | (event: "blur"): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
editor | Editor | null | The TipTap instance: your own command, your own extension, your own plugin. |
focus | () => boolean | undefined | Put the cursor in the field. Does nothing until the editor is mounted. |
blur | () => boolean | undefined | Remove the focus from the field. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrRichTextSize, GrRichTextConfigurableProps, GrRichTextProps } from '@feugene/granularity-editor/components/GrRichText'Filedist/types/components/GrRichText/config.d.ts
const grRichTextConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrRichText">;Filedist/types/components/GrRichText/grRichTextStyles.d.ts
type GrRichTextSize = 'xs' | 'sm' | 'md' | 'lg';const grRichTextSafelist: string[];Filedist/types/components/GrRichText/defaults.d.ts
interface GrRichTextConfigurableProps {
size?: GrRichTextSize;
}Filedist/types/components/GrRichText/GrRichText.vue.d.ts
interface GrRichTextProps {
modelValue?: string | Record<string, unknown> | null;
output?: 'html' | 'json';
schema?: GrRichTextSchemaName;
extensions?: GrRichTextExtension[];
placeholder?: string;
toolbar?: boolean | 'bubble' | 'both';
size?: GrRichTextSize;
id?: string;
name?: string;
disabled?: boolean;
readonly?: boolean;
invalid?: boolean;
required?: boolean;
ariaLabel?: string;
}interface GrRichTextEmits {
(e: 'update:modelValue', value: string | Record<string, unknown>): void;
(e: 'change', value: string | Record<string, unknown>): void;
(e: 'focus'): void;
(e: 'blur'): void;
}Usage from the library prose
The schema is also the sanitiser
There is no separate sanitiser in the package, and that is not an omission. ProseMirror parses the
input by the schema: a node or a mark that is not in it is discarded during the parsing, and on
the way out the document is serialised from that same tree. <script>, <iframe> and <img> do not
survive a paste — proven by a test rather than promised.
Hence a consequence worth knowing: what is not in the schema will not be in the value either. If
an article with pictures was pasted into a field with the minimal schema, the text remains. That is
a deliberate trade: a field accepting arbitrary markup breaks the layout of the page it will later be
shown on.
Two ready schemas
minimal is the style, a link, a list. article adds structure: headings, a quote, a code block.
Neither gives a first-level heading. There is one h1 on a page and it belongs to the page rather
than to an input field inside it; an editor that lets a second one be inserted breaks the structure of
the document for someone who was simply typing text.
The toolbar is assembled from the schema rather than written as markup
The buttons are built from the list of actions of the schema itself. Write them by hand and the very first edit of the schema would diverge from the panel silently: the button remained, and the command behind it is no longer there.
The panel wraps by whole groups rather than button by button. A flex-wrap over individual
buttons would tear the row where the room ran out and would separate buttons of one meaning:
“Heading” on one line, “Subheading” on another, and without a separator between them. The wrapper of a
group does not allow that — in a narrow field the lines read as the same blocks as one row.
The toolbar is a role="toolbar" with one Tab stop: inside, the arrows do the walking. Otherwise
reaching the text itself would take a dozen presses — “article” has ten buttons. The active format is
announced with aria-pressed rather than only by the highlighting: the highlighting does not exist
for a screen reader.
The bubble holds on the focus in the text
toolbar="bubble" puts the panel at the selection, "both" both above and at the selection.
The bubble lives exactly while there is a non-empty selection and focus in the field: the loss of focus is enough for it to go out. Hence two non-obvious consequences, and both sit in the code:
- the panel does not take the focus on opening — otherwise it would put itself out in the same frame in which it opened;
- a button of the bubble cancels
mousedown. Without that the focus would go to the button and the bubble would disappear under the cursor after the very first format — there would be nothing to apply a second one with. The panel above does not need that: it does not depend on the focus.
The buttons of the bubble are always of the smallest step, regardless of the size of the field. The panel above lives in the frame of the field and grows with it, while the bubble hangs over the text that is being read, and there the smallest area that gives a press target of 28×28 is appropriate — above the twenty-four required by WCAG 2.2. There is an arithmetic reason as well: the panel of a popover is limited in width, and a dozen buttons of the size of the field did not fit into it.
Esc closes the bubble, and a click outside does not: click would have to be listened for, and it
is exactly what a drag of the selection ends with, so the panel would close at the very moment it is
supposed to appear.
The anchor of the bubble is the rectangle of the selection in viewport coordinates, and scrolling
with the wheel does not change the selection. So while the bubble is on, the anchor is recalculated on
scroll and resize — scroll is listened for in the capture phase on window, so as to catch the
scrolling of a nested area as well, not only of the page. When the selection leaves the visible part
of the field or the page, the bubble hides rather than being left hanging over someone else’s
content; once it is back, the bubble is beside it again. Scrolling does not bring back a bubble closed
with Esc.
The visible part is also cut by the scroll-padding of every scroll area, the page included: that is
how a site declares its sticky header (html { scroll-padding-top: 80px }). A selection under the
header hides the bubble; right at its edge, where there is no room above the selection, the bubble
sits below it rather than on the header. The side is chosen by the field — GrPopover knows nothing
of scroll-padding and keeps clear of the viewport edge only.
Whether to show and on which side are two different questions with two different areas. Whether the
selection is visible is decided by all clipping ancestors, the field included: a selection that has
left the edge of its own scroll hides the bubble. The room for the bubble, though, is the page outside
the field: the bubble lives in the top layer, and the field does not clip it. The field’s own scroll
(.ProseMirror with overflow-y: auto) and its frame do not count — the bubble lies over the toolbar
and the field above the first line, and goes below only when there is no room above the selection on
the page, for example at a sticky header. Otherwise it would cover the next line, the one being read.
The keyboard path to the formats in this mode is the hotkeys: the panel at the selection is not a
Tab stop. If both roads are needed — toolbar="both".
The shape of the value is set with a prop
output="html" (the default) is a string of markup; output="json" is a TipTap document. The same
device as valueAdapter in granularity-chrono: the shape of the value is chosen by the consumer
rather than by the behaviour of the user.
Into a native form the value goes as a string in any mode: a hidden field cannot do objects.
What was saved is shown without the editor
The field is needed while the text is being edited. For showing what was saved it is not the best choice: ProseMirror requires a DOM, so on the server nothing is printed (see the next section), and in the browser an editable document with plugins, transactions and history is raised for the sake of a single paragraph.
The TipTap document is translated into markdown and given to GrMarkdown:
<script setup lang="ts">
import { computed } from 'vue'
import { markdownFromEditorDocument } from '@feugene/granularity-editor/markdown'
import GrMarkdown from '@feugene/granularity-editor/components/GrMarkdown'
const { document } = defineProps<{ document: object }>()
const source = computed(() => markdownFromEditorDocument(document))
</script>
<template>
<GrMarkdown :source="source" />
</template>There are no losses on this path for any of the schemas. The serialiser knows every node and
every mark they are able to produce — down to the line break by Shift+Enter; it serialises a
picture as well, although no schema gives one yet. When adding an extension of your own with a new
node, a branch in the serialiser will have to be added along with it: what is lost silently is
exactly what you added.
It is also fit for storage: output="json" holds the TipTap document, while
markdownFromEditorDocument puts markdown into the database — a form that both a person and any
other tool will read.
The content is not printed on the server
ProseMirror requires a DOM, so the editor is raised after mounting, and the server markup is a shell
with data-allow-mismatch. There is no text in it, but there is room: the host holds a placeholder
with the same box the input area will take — --gr-rich-text-min-height and
--gr-rich-text-max-height are shared by both, in one rule. A field with min = max does not move on
hydration; a value taller than min-height with an open max-height grows to fit the text after
mounting — see ../ssr.md.
There is deliberately no v-html in the package: printing someone else’s markup for the sake of the
first frame would mean introducing the single XSS surface exactly where the data comes from the user.
A field is input rather than publication.
Extensions of your own
extensions adds TipTap extensions to the schema rather than replacing it:
<script setup lang="ts">
import { CharacterCount } from '@tiptap/extensions'
</script>
<template>
<GrRichText v-model="text" :extensions="[CharacterCount.configure({ limit: 500 })]" />
</template>The ready extensions are listed in the TipTap catalogue, and one of your own is written by the guide. The package does not wrap them: what was handed over is what the editor gets.
A change of the set — like a change of schema — rebuilds the editor: a ProseMirror schema is
immutable, and both the document and the commands are derived from it. The text is carried over as
markup and goes through the parsing again, so a node that is not in the new schema is discarded — the
same rule as on a paste.
The toolbar will not show a button for an extension of your own: it is built from the schema, and a
button with no command behind it would be a deception. A button of your own is put in with the
#action-<key> slot or with a panel of your own over the instance from defineExpose.
Limits
- not a CMS: no pictures, no mentions, no tables in this release. A TipTap extension is added by
the consumer themselves through
extensions, and the instance of the editor is given away by the component throughdefineExpose. The toolbar will not show a button for an extension of your own: it is built from the schema rather than from the set of extensions; - no collaborative editing: simultaneous editing by two people requires a transport and conflict resolution, and that is not the task of an input field.
Props in this example