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
126 changes: 126 additions & 0 deletions Documentation/MarkdownEditor/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
title: MarkdownEditor
description: Write markdown in one bordered box with a formatting toolbar, a preview toggle, configurable autocompletion, and file uploads.
---

`MarkdownEditor` is a controlled markdown editor: a formatting toolbar above the writing area, and a round toggle in the corner that swaps the whole box to the rendered preview. It fills the container it is given, completes whatever triggers you configure, and uploads pasted or dropped files through a callback you supply.

## Basic usage

```tsx
import { useState } from 'react';
import { MarkdownEditor } from '@cratis/components/MarkdownEditor';

export function DescriptionEditor() {
const [description, setDescription] = useState('');

return (
<MarkdownEditor
value={description}
onChange={setDescription}
placeholder='Describe the change'
aria-label='Description'
/>
);
}
```

Import the stylesheet once, either the aggregate `@cratis/components/styles` or the area sheet `@cratis/components/MarkdownEditor/styles`.

## Show a preview

The editor ships no markdown renderer. Rendering, and which HTML is allowed through, belongs to your application, so pass the renderer you already use for read-only markdown through `renderPreview`. The toggle appears only when a renderer is given:

```tsx
<MarkdownEditor
value={description}
onChange={setDescription}
renderPreview={markdown => <MarkdownView source={markdown} />}
/>
```

`renderPreview` is never called with empty markdown; the editor shows its `emptyPreview` label instead. Use `initialMode={MarkdownEditorMode.Preview}` to open on the preview.

## Autocomplete a trigger

A completion names what starts it and where its suggestions come from. The editor recognizes the trigger at the caret, waits for typing to pause, calls `suggest`, renders the list at the caret, and writes the picked suggestion's `insertText` over the trigger and the query:

```tsx
import type { MarkdownCompletion } from '@cratis/components/MarkdownEditor';

const issues: MarkdownCompletion = {
trigger: '#',
allowSpaces: true,
label: 'Issues',
suggest: async (query, { signal }) => {
const response = await fetch(`/api/issues?search=${encodeURIComponent(query)}`, { signal });
const found: { number: number; title: string }[] = await response.json();
return found.map(issue => ({
id: String(issue.number),
insertText: `#${issue.number}`,
label: issue.title,
detail: `#${issue.number}`,
}));
},
};

<MarkdownEditor value={body} onChange={setBody} completions={[issues]} />;
```

- A string `trigger` starts a completion at the start of a line or after whitespace. The query is what follows it on the same line; set `allowSpaces` to search by title rather than a single word.
- A `RegExp` trigger is matched against the current line up to the caret. The whole match is replaced, and the query is the named group `query`, the first group, or the whole match. For example, `/(?<=^|\s)\[\[(?<query>[^\]]*)$/u` completes `[[` wiki links.
- `suggest` may answer synchronously or with a promise. An answer for a query the person has typed past is dropped, the `signal` is aborted when the answer is no longer wanted, and a rejected answer closes the list.
- `debounce` (default `150` milliseconds) and `minimumQueryLength` (default `0`) decide when to ask.
- Each suggestion shows its `detail`, `label` and `annotation`. Use `renderSuggestion` to draw your own content.

The arrow keys move the highlight, Enter or Tab picks it, and Escape dismisses the list until the trigger is typed again. Focus stays in the writing area throughout. The list opens above any dialog the editor sits in and is not hidden or dismissed by it.

## Upload pasted and dropped files

Give `uploadFile` to take over pasted and dropped files. The editor puts a placeholder at the caret straight away, calls `uploadFile`, and replaces the placeholder with the markdown it returns:

```tsx
<MarkdownEditor
value={body}
onChange={setBody}
uploadFile={async file => {
const response = await fetch('/api/files', { method: 'POST', body: file });
if (!response.ok) throw new Error(response.statusText);
const { url } = await response.json();
return file.type.startsWith('image/') ? `![${file.name}](${url})` : `[${file.name}](${url})`;
}}
onUploadFailed={file => notify(`Could not upload ${file.name}`)}
/>
```

When the upload rejects, the placeholder is taken out again and `onUploadFailed` is told. A status line announces the files that are uploading, and the root carries `data-busy` while it shows.

## Formatting

The toolbar offers headings, bold, italic, strikethrough, quotes, inline code, code blocks, links, and bulleted, numbered and task lists. Every format is a toggle: applying it where it is already applied takes it off. Control+B, Control+I and Control+K (Command on macOS) apply bold, italic and a link. The toolbar is one Tab stop; the arrow keys, Home and End move between its buttons.

Choose the formats, in groups, with `formats`, or pass `formats={false}` for no toolbar:

```tsx
import { MarkdownFormat } from '@cratis/components/MarkdownEditor';

<MarkdownEditor
value={note}
onChange={setNote}
formats={[[MarkdownFormat.Bold, MarkdownFormat.Italic], [MarkdownFormat.Link]]}
/>
```

The same edits are available without the editor through `applyMarkdownFormat(value, selectionStart, selectionEnd, format)`.

## Size

The editor fills the height of its container. When the container leaves its height to its content, the editor falls back to `height`, which is 320 pixels unless you pass another number of pixels or a CSS length. A long document scrolls inside the editor rather than growing it.

## Labels and accessibility

Every label has an English default and can be replaced through `labels`, including the toolbar buttons, the toggle, the empty preview, the suggestion list and the upload status. Name the writing area with `aria-label`, `aria-labelledby`, or a `<label htmlFor>` pointing at `id`. `invalid` sets `aria-invalid`, and `aria-describedby` can point at a validation message.

## Styling

Style the stable parts through `pt` or the `data-cratis-part` attributes: `root`, `toolbar`, `format`, `toggle`, `textarea`, `preview`, `status`, `suggestions` and `suggestion`. The root carries `data-disabled`, `data-invalid`, `data-readonly` and `data-busy`; the toggle carries `data-pressed` while the preview shows; the highlighted suggestion carries `data-selected`. Colors come from the `--cratis-*` tokens.
2 changes: 2 additions & 0 deletions Documentation/MarkdownEditor/toc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- name: MarkdownEditor
href: index.md
1 change: 1 addition & 0 deletions Documentation/choosing-a-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The question is whether confirming the form **runs a command**, and whether it's
| Collect data and return it **without** running a command | [`Dialog`](./Dialogs/dialog.md) | A confirmation or data-entry dialog that hands values back to the caller. No command involved. |
| Edit ordinary local React state | [`Common` basic controls](./Common/basic-controls.md) | Native text and choice controls expose semantic values without binding an Arc command. |
| Select one or more values in ordinary local React state | [`Dropdown`](./Dropdown/index.md) | Binds a value or array to local options without binding an Arc command. |
| Write markdown with formatting, a preview, and completion | [`MarkdownEditor`](./MarkdownEditor/index.md) | A controlled editor with a toolbar, host-rendered preview, configurable triggers, and file uploads. |

Rule of thumb: **if confirming the dialog executes a generated command, it's a `CommandDialog`** (or its
stepper variant). If it just gathers values and returns them, it's a `Dialog`. Never reach for
Expand Down
2 changes: 2 additions & 0 deletions Documentation/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
href: Filter/toc.yml
- name: Dropdown
href: Dropdown/toc.yml
- name: MarkdownEditor
href: MarkdownEditor/toc.yml
- name: Display
href: Display/toc.yml
- name: Notifications
Expand Down
4 changes: 4 additions & 0 deletions Migrator/test/transform.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ describe('root namespace maps', () => {
'./CommandStepper',
// Nested refinement of the historical CommandForm namespace.
'./CommandForm/fields',
// Added after Components 4.0 and never a Components 3 root namespace, so no Components 3
// import can name it and the codemod has nothing to rewrite.
'./MarkdownEditor',
// Per-area CSS entry points (feat/per-area-stylesheets): side-effect stylesheet
// imports, not symbol imports, so the codemod has nothing to rewrite for them —
// same as the pre-existing aggregate './styles' above.
Expand All @@ -192,6 +195,7 @@ describe('root namespace maps', () => {
'./Display/styles',
'./Dropdown/styles',
'./Filter/styles',
'./MarkdownEditor/styles',
'./Notifications/styles',
'./ObjectContentEditor/styles',
'./ObjectNavigationalBar/styles',
Expand Down
16 changes: 16 additions & 0 deletions Source/MarkdownEditor/CaretPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/**
* Where the caret of a writing area is on screen, in viewport coordinates.
*/
export interface CaretPosition {
/** The left edge of the caret. */
left: number;

/** The top of the line the caret is on. */
top: number;

/** The bottom of the line the caret is on. */
bottom: number;
}
75 changes: 75 additions & 0 deletions Source/MarkdownEditor/MarkdownCompletion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import type { ReactNode } from 'react';
import type { MarkdownCompletionContext } from './MarkdownCompletionContext';
import type { MarkdownSuggestion } from './MarkdownSuggestion';

/**
* Configures autocompletion for a {@link MarkdownEditor}: what starts it, and where the suggestions
* come from. The editor recognizes the trigger at the caret, asks {@link suggest} for suggestions,
* renders the list, and writes the picked suggestion's text over the trigger and what followed it.
*
* ```ts
* const issues: MarkdownCompletion = {
* trigger: '#',
* allowSpaces: true,
* label: 'Issues',
* suggest: async (query, { signal }) => (await searchIssues(query, signal)).map(issue => ({
* id: issue.id,
* insertText: `#${issue.number}`,
* label: issue.title,
* detail: `#${issue.number}`,
* })),
* };
* ```
*/
export interface MarkdownCompletion {
/**
* What starts a completion.
*
* A string, such as `'#'` or `'@'`, starts one when it is typed at the start of a line or after
* whitespace; what follows it up to the caret, on the same line, is the query.
*
* A regular expression is matched against the current line up to the caret and starts a completion
* when its match ends at the caret. The whole match is what a picked suggestion replaces, and the
* query is the named group `query` when there is one, otherwise the first group, otherwise the
* whole match. Anchor it with `$` and use a lookbehind such as `(?<=^|\s)` for word boundaries.
*/
trigger: string | RegExp;

/**
* Whether the query of a string trigger may contain spaces - searching by title rather than by a
* single word. Leading spaces are dropped from the query. Defaults to `false`.
*/
allowSpaces?: boolean;

/** How many characters must follow the trigger before suggestions are asked for. Defaults to `0`. */
minimumQueryLength?: number;

/** How long typing must pause, in milliseconds, before suggestions are asked for. Defaults to `150`. */
debounce?: number;

/**
* Returns the suggestions for what has been typed after the trigger. May answer synchronously or
* with a promise; an answer for a query the person has already typed past is dropped, and so is a
* rejected one. An empty answer closes the list.
* @param query What has been typed after the trigger.
* @param context The {@link MarkdownCompletionContext} of the request.
* @returns The suggestions to offer, in the order to offer them.
*/
suggest: (
query: string,
context: MarkdownCompletionContext,
) => readonly MarkdownSuggestion[] | Promise<readonly MarkdownSuggestion[]>;

/** Accessible name of the suggestion list. Defaults to the editor's `suggestions` label. */
label?: string;

/**
* Renders the content of one suggestion in place of the default detail, label and annotation.
* @param suggestion The suggestion to render.
* @returns What to show for it.
*/
renderSuggestion?: (suggestion: MarkdownSuggestion) => ReactNode;
}
13 changes: 13 additions & 0 deletions Source/MarkdownEditor/MarkdownCompletionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/**
* What a {@link MarkdownCompletion} is told alongside the query when it is asked for suggestions.
*/
export interface MarkdownCompletionContext {
/**
* Aborted as soon as the answer is no longer wanted - the person typed on, moved the caret away or
* picked something. Pass it to `fetch` or check it before doing more work.
*/
signal: AbortSignal;
}
24 changes: 24 additions & 0 deletions Source/MarkdownEditor/MarkdownCompletionMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import type { MarkdownCompletion } from './MarkdownCompletion';

/**
* A completion trigger found at the caret, and the query typed after it.
*/
export interface MarkdownCompletionMatch {
/** The completion whose trigger was found. */
completion: MarkdownCompletion;

/** The position of the matched completion in the list it was configured in. */
completionIndex: number;

/** Where the trigger starts - the start of what a picked suggestion replaces. */
start: number;

/** The caret - the end of what a picked suggestion replaces. */
end: number;

/** What has been typed after the trigger. */
query: string;
}
Loading
Loading