@orvain/utils 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +64 -0
- package/README.md +48 -2
- package/dist/a11y.d.cts +102 -0
- package/dist/a11y.d.ts +103 -0
- package/dist/breakpoints.d.cts +36 -0
- package/dist/breakpoints.d.ts +37 -0
- package/dist/calendar.d.cts +127 -0
- package/dist/calendar.d.ts +128 -0
- package/dist/chart/color.d.cts +72 -0
- package/dist/chart/color.d.ts +73 -0
- package/dist/chart/index.d.cts +2 -0
- package/dist/chart/index.d.ts +3 -0
- package/dist/chart/math.d.cts +66 -0
- package/dist/chart/math.d.ts +67 -0
- package/dist/chart-items/index.d.cts +64 -0
- package/dist/chart-items/index.d.ts +65 -0
- package/dist/codeTokenizer.d.cts +29 -0
- package/dist/codeTokenizer.d.ts +30 -0
- package/dist/cssModule.d.cts +12 -0
- package/dist/cssModule.d.ts +13 -0
- package/dist/dismiss.d.cts +40 -0
- package/dist/dismiss.d.ts +41 -0
- package/dist/floating.d.cts +137 -0
- package/dist/floating.d.ts +138 -0
- package/dist/formField.d.cts +58 -0
- package/dist/formField.d.ts +59 -0
- package/dist/helpers.d.cts +17 -0
- package/dist/helpers.d.ts +18 -0
- package/dist/index.cjs +11 -0
- package/dist/index.d.cts +17 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +2819 -0
- package/dist/kanbanDnd.d.cts +71 -0
- package/dist/kanbanDnd.d.ts +72 -0
- package/dist/listNavigation.d.cts +36 -0
- package/dist/listNavigation.d.ts +37 -0
- package/dist/richText/blockquote.d.cts +11 -0
- package/dist/richText/blockquote.d.ts +12 -0
- package/dist/richText/editor.d.cts +117 -0
- package/dist/richText/editor.d.ts +118 -0
- package/dist/richText/index.d.cts +3 -0
- package/dist/richText/index.d.ts +4 -0
- package/dist/richText/model.d.cts +161 -0
- package/dist/richText/model.d.ts +162 -0
- package/dist/slider.d.cts +18 -0
- package/dist/slider.d.ts +19 -0
- package/dist/tree.d.cts +78 -0
- package/dist/tree.d.ts +79 -0
- package/dist/types.d.cts +22 -0
- package/dist/types.d.ts +23 -0
- package/package.json +56 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free drag and drop for KanbanBoard (React + Vue).
|
|
3
|
+
*
|
|
4
|
+
* Pointer events only (mouse, pen and touch; no HTML5 DnD, no library), plus
|
|
5
|
+
* the keyboard alternative of the "accessible drag and drop" pattern:
|
|
6
|
+
* Space picks a card up, arrow keys move it (up/down within a column,
|
|
7
|
+
* left/right between columns), Space (or Enter) drops it and Escape cancels.
|
|
8
|
+
* Every step is announced through the board's live region.
|
|
9
|
+
*
|
|
10
|
+
* The board stays controlled: nothing in the DOM is moved here. On a drop the
|
|
11
|
+
* controller calls `onMove({ cardId, fromColumn, toColumn, index })`, the app
|
|
12
|
+
* updates its data and re-renders, and the controller puts focus back on the
|
|
13
|
+
* moved card (keyboard drops).
|
|
14
|
+
*
|
|
15
|
+
* DOM contract (set by the components):
|
|
16
|
+
* [data-kanban-column="<id>"][data-kanban-column-label="<title>"] column
|
|
17
|
+
* [data-kanban-card="<id>"][data-kanban-card-label="<title>"] card
|
|
18
|
+
* [data-kanban-handle] the card's focus target
|
|
19
|
+
* State the controller writes (styled by the CSS module):
|
|
20
|
+
* board [data-kanban-dragging="pointer|keyboard"]
|
|
21
|
+
* card [data-kanban-lifted="pointer|keyboard"] (pointer: the card stays as a
|
|
22
|
+
* dimmed placeholder; a clone with [data-kanban-lifted="preview"]
|
|
23
|
+
* follows the pointer in a position:fixed layer on <body>, so the
|
|
24
|
+
* board's scroll box never clips it)
|
|
25
|
+
* handle tabindex: roving, one Tab stop per column (Up/Down between cards,
|
|
26
|
+
* Left/Right to the same position in the next column)
|
|
27
|
+
* column [data-kanban-drop-target]
|
|
28
|
+
* li [data-kanban-drop="before|after"]
|
|
29
|
+
*/
|
|
30
|
+
export interface KanbanCardMove {
|
|
31
|
+
cardId: string;
|
|
32
|
+
fromColumn: string;
|
|
33
|
+
toColumn: string;
|
|
34
|
+
/** Index in `toColumn` after the move (the card removed from its old place first). */
|
|
35
|
+
index: number;
|
|
36
|
+
/** Index the card had in `fromColumn`. */
|
|
37
|
+
fromIndex: number;
|
|
38
|
+
}
|
|
39
|
+
export interface KanbanDndPosition {
|
|
40
|
+
/** Card title. */
|
|
41
|
+
card: string;
|
|
42
|
+
/** Column title the card is over. */
|
|
43
|
+
column: string;
|
|
44
|
+
/** 1-based position in that column. */
|
|
45
|
+
position: number;
|
|
46
|
+
/** Positions in that column. */
|
|
47
|
+
total: number;
|
|
48
|
+
/** Column title the card was picked up from. */
|
|
49
|
+
fromColumn: string;
|
|
50
|
+
fromPosition: number;
|
|
51
|
+
fromTotal: number;
|
|
52
|
+
}
|
|
53
|
+
export interface KanbanDndAnnouncements {
|
|
54
|
+
pickedUp: (p: KanbanDndPosition) => string;
|
|
55
|
+
moved: (p: KanbanDndPosition) => string;
|
|
56
|
+
dropped: (p: KanbanDndPosition) => string;
|
|
57
|
+
cancelled: (p: KanbanDndPosition) => string;
|
|
58
|
+
}
|
|
59
|
+
export declare const defaultKanbanAnnouncements: KanbanDndAnnouncements;
|
|
60
|
+
export declare const defaultKanbanInstructions: string;
|
|
61
|
+
export interface KanbanDndOptions {
|
|
62
|
+
onMove: (move: KanbanCardMove) => void;
|
|
63
|
+
/** Writes into the board's aria-live region. */
|
|
64
|
+
announce: (text: string) => void;
|
|
65
|
+
announcements?: Partial<KanbanDndAnnouncements> | undefined;
|
|
66
|
+
}
|
|
67
|
+
export interface KanbanDndController {
|
|
68
|
+
update: (options: KanbanDndOptions) => void;
|
|
69
|
+
destroy: () => void;
|
|
70
|
+
}
|
|
71
|
+
export declare function createKanbanDnd(board: HTMLElement, initial: KanbanDndOptions): KanbanDndController;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free drag and drop for KanbanBoard (React + Vue).
|
|
3
|
+
*
|
|
4
|
+
* Pointer events only (mouse, pen and touch; no HTML5 DnD, no library), plus
|
|
5
|
+
* the keyboard alternative of the "accessible drag and drop" pattern:
|
|
6
|
+
* Space picks a card up, arrow keys move it (up/down within a column,
|
|
7
|
+
* left/right between columns), Space (or Enter) drops it and Escape cancels.
|
|
8
|
+
* Every step is announced through the board's live region.
|
|
9
|
+
*
|
|
10
|
+
* The board stays controlled: nothing in the DOM is moved here. On a drop the
|
|
11
|
+
* controller calls `onMove({ cardId, fromColumn, toColumn, index })`, the app
|
|
12
|
+
* updates its data and re-renders, and the controller puts focus back on the
|
|
13
|
+
* moved card (keyboard drops).
|
|
14
|
+
*
|
|
15
|
+
* DOM contract (set by the components):
|
|
16
|
+
* [data-kanban-column="<id>"][data-kanban-column-label="<title>"] column
|
|
17
|
+
* [data-kanban-card="<id>"][data-kanban-card-label="<title>"] card
|
|
18
|
+
* [data-kanban-handle] the card's focus target
|
|
19
|
+
* State the controller writes (styled by the CSS module):
|
|
20
|
+
* board [data-kanban-dragging="pointer|keyboard"]
|
|
21
|
+
* card [data-kanban-lifted="pointer|keyboard"] (pointer: the card stays as a
|
|
22
|
+
* dimmed placeholder; a clone with [data-kanban-lifted="preview"]
|
|
23
|
+
* follows the pointer in a position:fixed layer on <body>, so the
|
|
24
|
+
* board's scroll box never clips it)
|
|
25
|
+
* handle tabindex: roving, one Tab stop per column (Up/Down between cards,
|
|
26
|
+
* Left/Right to the same position in the next column)
|
|
27
|
+
* column [data-kanban-drop-target]
|
|
28
|
+
* li [data-kanban-drop="before|after"]
|
|
29
|
+
*/
|
|
30
|
+
export interface KanbanCardMove {
|
|
31
|
+
cardId: string;
|
|
32
|
+
fromColumn: string;
|
|
33
|
+
toColumn: string;
|
|
34
|
+
/** Index in `toColumn` after the move (the card removed from its old place first). */
|
|
35
|
+
index: number;
|
|
36
|
+
/** Index the card had in `fromColumn`. */
|
|
37
|
+
fromIndex: number;
|
|
38
|
+
}
|
|
39
|
+
export interface KanbanDndPosition {
|
|
40
|
+
/** Card title. */
|
|
41
|
+
card: string;
|
|
42
|
+
/** Column title the card is over. */
|
|
43
|
+
column: string;
|
|
44
|
+
/** 1-based position in that column. */
|
|
45
|
+
position: number;
|
|
46
|
+
/** Positions in that column. */
|
|
47
|
+
total: number;
|
|
48
|
+
/** Column title the card was picked up from. */
|
|
49
|
+
fromColumn: string;
|
|
50
|
+
fromPosition: number;
|
|
51
|
+
fromTotal: number;
|
|
52
|
+
}
|
|
53
|
+
export interface KanbanDndAnnouncements {
|
|
54
|
+
pickedUp: (p: KanbanDndPosition) => string;
|
|
55
|
+
moved: (p: KanbanDndPosition) => string;
|
|
56
|
+
dropped: (p: KanbanDndPosition) => string;
|
|
57
|
+
cancelled: (p: KanbanDndPosition) => string;
|
|
58
|
+
}
|
|
59
|
+
export declare const defaultKanbanAnnouncements: KanbanDndAnnouncements;
|
|
60
|
+
export declare const defaultKanbanInstructions: string;
|
|
61
|
+
export interface KanbanDndOptions {
|
|
62
|
+
onMove: (move: KanbanCardMove) => void;
|
|
63
|
+
/** Writes into the board's aria-live region. */
|
|
64
|
+
announce: (text: string) => void;
|
|
65
|
+
announcements?: Partial<KanbanDndAnnouncements> | undefined;
|
|
66
|
+
}
|
|
67
|
+
export interface KanbanDndController {
|
|
68
|
+
update: (options: KanbanDndOptions) => void;
|
|
69
|
+
destroy: () => void;
|
|
70
|
+
}
|
|
71
|
+
export declare function createKanbanDnd(board: HTMLElement, initial: KanbanDndOptions): KanbanDndController;
|
|
72
|
+
//# sourceMappingURL=kanbanDnd.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index arithmetic behind the WAI-ARIA APG listbox / menu / toolbar keyboard
|
|
3
|
+
* model, shared by React `useListNavigation` and its Vue twin. Items are
|
|
4
|
+
* addressed by index; disabled items are skipped.
|
|
5
|
+
*/
|
|
6
|
+
export type ListOrientation = 'vertical' | 'horizontal' | 'both';
|
|
7
|
+
export type ListMove = 'next' | 'prev' | 'first' | 'last';
|
|
8
|
+
/** First enabled index from `from` in `step` direction (inclusive), or -1. */
|
|
9
|
+
export declare function findEnabledIndex(count: number, isDisabled: (index: number) => boolean, from: number, step: 1 | -1, loop: boolean): number;
|
|
10
|
+
/**
|
|
11
|
+
* Index `move` lands on from `current` (-1 = nothing active). With nothing
|
|
12
|
+
* active, next starts at the first item and prev at the last. Without `loop`
|
|
13
|
+
* the ends hold.
|
|
14
|
+
*/
|
|
15
|
+
export declare function moveIndex(move: ListMove, current: number, count: number, isDisabled: (index: number) => boolean, loop: boolean): number;
|
|
16
|
+
/** The move a key requests for an orientation, or null when the key isn't navigation. */
|
|
17
|
+
export declare function keyToMove(key: string, orientation: ListOrientation, rtl?: boolean, homeEnd?: boolean): ListMove | null;
|
|
18
|
+
/** Typed characters are joined while keys arrive less than this far apart (APG). */
|
|
19
|
+
export declare const TYPEAHEAD_TIMEOUT_MS = 500;
|
|
20
|
+
export interface Typeahead {
|
|
21
|
+
/**
|
|
22
|
+
* Feeds one key. Returns the index to activate, -1 when nothing matches,
|
|
23
|
+
* or null when the key isn't part of typeahead (not printable, or a space
|
|
24
|
+
* that doesn't continue a search).
|
|
25
|
+
*/
|
|
26
|
+
handle(key: string, current: number, count: number, getLabel: (index: number) => string, isDisabled: (index: number) => boolean, now?: number): number | null;
|
|
27
|
+
/** True while a search is in progress, so Space joins it instead of selecting. */
|
|
28
|
+
active(now?: number): boolean;
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* APG typeahead: the typed string matches the start of an enabled item's
|
|
33
|
+
* label (case-insensitive), searching from the item after the active one.
|
|
34
|
+
* Repeating one letter cycles through the items that start with it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createTypeahead(timeout?: number): Typeahead;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index arithmetic behind the WAI-ARIA APG listbox / menu / toolbar keyboard
|
|
3
|
+
* model, shared by React `useListNavigation` and its Vue twin. Items are
|
|
4
|
+
* addressed by index; disabled items are skipped.
|
|
5
|
+
*/
|
|
6
|
+
export type ListOrientation = 'vertical' | 'horizontal' | 'both';
|
|
7
|
+
export type ListMove = 'next' | 'prev' | 'first' | 'last';
|
|
8
|
+
/** First enabled index from `from` in `step` direction (inclusive), or -1. */
|
|
9
|
+
export declare function findEnabledIndex(count: number, isDisabled: (index: number) => boolean, from: number, step: 1 | -1, loop: boolean): number;
|
|
10
|
+
/**
|
|
11
|
+
* Index `move` lands on from `current` (-1 = nothing active). With nothing
|
|
12
|
+
* active, next starts at the first item and prev at the last. Without `loop`
|
|
13
|
+
* the ends hold.
|
|
14
|
+
*/
|
|
15
|
+
export declare function moveIndex(move: ListMove, current: number, count: number, isDisabled: (index: number) => boolean, loop: boolean): number;
|
|
16
|
+
/** The move a key requests for an orientation, or null when the key isn't navigation. */
|
|
17
|
+
export declare function keyToMove(key: string, orientation: ListOrientation, rtl?: boolean, homeEnd?: boolean): ListMove | null;
|
|
18
|
+
/** Typed characters are joined while keys arrive less than this far apart (APG). */
|
|
19
|
+
export declare const TYPEAHEAD_TIMEOUT_MS = 500;
|
|
20
|
+
export interface Typeahead {
|
|
21
|
+
/**
|
|
22
|
+
* Feeds one key. Returns the index to activate, -1 when nothing matches,
|
|
23
|
+
* or null when the key isn't part of typeahead (not printable, or a space
|
|
24
|
+
* that doesn't continue a search).
|
|
25
|
+
*/
|
|
26
|
+
handle(key: string, current: number, count: number, getLabel: (index: number) => string, isDisabled: (index: number) => boolean, now?: number): number | null;
|
|
27
|
+
/** True while a search is in progress, so Space joins it instead of selecting. */
|
|
28
|
+
active(now?: number): boolean;
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* APG typeahead: the typed string matches the start of an enabled item's
|
|
33
|
+
* label (case-insensitive), searching from the item after the active one.
|
|
34
|
+
* Repeating one letter cycles through the items that start with it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createTypeahead(timeout?: number): Typeahead;
|
|
37
|
+
//# sourceMappingURL=listNavigation.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block quotes in editors built on the browser's own editing commands
|
|
3
|
+
* (`RichEditor`). `TextAreaWithToolbar` keeps quotes in its document model instead.
|
|
4
|
+
*/
|
|
5
|
+
/** The `<blockquote>` inside `root` that holds the selection's start, if any. */
|
|
6
|
+
export declare function blockquoteAtSelection(root: Element): HTMLQuoteElement | null;
|
|
7
|
+
/**
|
|
8
|
+
* Quotes the selected paragraphs, or unquotes them when the selection is
|
|
9
|
+
* already in a quote. Returns whether the selection ends up quoted.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toggleBlockquote(root: Element): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block quotes in editors built on the browser's own editing commands
|
|
3
|
+
* (`RichEditor`). `TextAreaWithToolbar` keeps quotes in its document model instead.
|
|
4
|
+
*/
|
|
5
|
+
/** The `<blockquote>` inside `root` that holds the selection's start, if any. */
|
|
6
|
+
export declare function blockquoteAtSelection(root: Element): HTMLQuoteElement | null;
|
|
7
|
+
/**
|
|
8
|
+
* Quotes the selected paragraphs, or unquotes them when the selection is
|
|
9
|
+
* already in a quote. Returns whether the selection ends up quoted.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toggleBlockquote(root: Element): boolean;
|
|
12
|
+
//# sourceMappingURL=blockquote.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour of a `contenteditable` rich-text field, shared by the React
|
|
3
|
+
* and Vue `TextAreaWithToolbar` so both behave identically. No `document.execCommand`:
|
|
4
|
+
* formatting is applied to the document model (./model.ts) and the editor
|
|
5
|
+
* re-renders from it, so the output is always the sanitized allow-list.
|
|
6
|
+
*
|
|
7
|
+
* Plain typing inside one paragraph stays native (spell-check, autocorrect,
|
|
8
|
+
* IME); everything that would let the browser invent markup (line breaks,
|
|
9
|
+
* joining paragraphs, deleting across them, paste, drop, its own format
|
|
10
|
+
* commands) goes through the model. Native undo cannot see model edits, so
|
|
11
|
+
* the editor keeps its own history and answers Ctrl/Cmd+Z, Ctrl+Y and
|
|
12
|
+
* Ctrl/Cmd+Shift+Z (plus the browser's Undo / Redo menu items).
|
|
13
|
+
*
|
|
14
|
+
* Quote and indent are paragraph formats: Ctrl/Cmd+] indents, Ctrl/Cmd+[
|
|
15
|
+
* outdents and Ctrl/Cmd+Shift+9 toggles the quote (when `blockShortcuts`
|
|
16
|
+
* allows), and Backspace at the start of an indented or quoted paragraph
|
|
17
|
+
* removes the indent, then the quote, before it joins the paragraph above.
|
|
18
|
+
* Tab is never captured: it keeps moving focus, as in any form field.
|
|
19
|
+
*/
|
|
20
|
+
import { type RichTextAlign, type RichTextFormat, type RichTextMark, type RichTextMarkPatch, type RichTextStyle } from './model.cjs';
|
|
21
|
+
export interface RichTextEditorOptions {
|
|
22
|
+
/** Fires when the sanitized value changes through editing (not through `setValue`). */
|
|
23
|
+
onChange?: (value: string, text: string) => void;
|
|
24
|
+
/** Fires when the format at the selection changes (drives the toolbar). */
|
|
25
|
+
onFormatChange?: (format: RichTextFormat) => void;
|
|
26
|
+
/** Formatting for text typed before the caret is first moved (e.g. start in bold). */
|
|
27
|
+
defaultMarks?: RichTextMarkPatch | null | undefined;
|
|
28
|
+
/** Whether the quote / indent shortcuts are on (e.g. only while their buttons are shown). */
|
|
29
|
+
blockShortcuts?: () => boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface RichTextCommandOptions {
|
|
32
|
+
/** Move focus back into the editor afterwards (pointer use of a toolbar button). */
|
|
33
|
+
focus?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare class RichTextEditor {
|
|
36
|
+
readonly root: HTMLElement;
|
|
37
|
+
private readonly options;
|
|
38
|
+
private doc;
|
|
39
|
+
private items;
|
|
40
|
+
private value;
|
|
41
|
+
private lastSelection;
|
|
42
|
+
private pending;
|
|
43
|
+
/** Caret the pending marks belong to; `null` until the caret is first placed. */
|
|
44
|
+
private pendingAt;
|
|
45
|
+
private history;
|
|
46
|
+
private historyIndex;
|
|
47
|
+
private lastEdit;
|
|
48
|
+
private composing;
|
|
49
|
+
private compositionStart;
|
|
50
|
+
private dragRange;
|
|
51
|
+
/** The DOM was re-rendered while focus was elsewhere, so its selection is gone. */
|
|
52
|
+
private selectionStale;
|
|
53
|
+
private formatKey;
|
|
54
|
+
private readonly cleanup;
|
|
55
|
+
constructor(root: HTMLElement, options?: RichTextEditorOptions);
|
|
56
|
+
destroy(): void;
|
|
57
|
+
/** The sanitized HTML value (`''` when empty). */
|
|
58
|
+
getValue(): string;
|
|
59
|
+
getText(): string;
|
|
60
|
+
getFormat(): RichTextFormat;
|
|
61
|
+
/** Replaces the content from outside (a controlled value). Does not fire `onChange`. */
|
|
62
|
+
setValue(html: string): void;
|
|
63
|
+
/** Bold / italic / underline: toggles the selection, or the typing format at a caret. */
|
|
64
|
+
toggleMark(mark: RichTextMark, options?: RichTextCommandOptions): void;
|
|
65
|
+
/** Colour, font family or size for the selection (or the typing format); `null` clears it. */
|
|
66
|
+
applyStyle(style: RichTextStyle, value: string | null, options?: RichTextCommandOptions): void;
|
|
67
|
+
/** Aligns every paragraph the selection touches (the caret's paragraph when collapsed). */
|
|
68
|
+
setAlign(align: RichTextAlign, options?: RichTextCommandOptions): void;
|
|
69
|
+
/** Quotes (or unquotes) every paragraph the selection touches. */
|
|
70
|
+
setQuote(on: boolean, options?: RichTextCommandOptions): void;
|
|
71
|
+
/** Sets the indent level (0 = none) of every paragraph the selection touches. */
|
|
72
|
+
setIndent(level: number | ((current: number) => number), options?: RichTextCommandOptions): void;
|
|
73
|
+
focus(): void;
|
|
74
|
+
undo(): void;
|
|
75
|
+
redo(): void;
|
|
76
|
+
private applyPatch;
|
|
77
|
+
private replace;
|
|
78
|
+
private insertText;
|
|
79
|
+
private afterModelEdit;
|
|
80
|
+
/** Publishes a changed value and records it in the history. */
|
|
81
|
+
private commit;
|
|
82
|
+
private record;
|
|
83
|
+
private stepHistory;
|
|
84
|
+
private emitFormat;
|
|
85
|
+
private render;
|
|
86
|
+
/** Re-reads the DOM after native editing; rewrites it if the browser left non-canonical markup. */
|
|
87
|
+
private syncFromDom;
|
|
88
|
+
private isFocused;
|
|
89
|
+
private endSelection;
|
|
90
|
+
/**
|
|
91
|
+
* The live selection while the editor has focus, else the last one it had
|
|
92
|
+
* (focus on a toolbar button leaves the document selection behind).
|
|
93
|
+
*/
|
|
94
|
+
private currentSelection;
|
|
95
|
+
private domSelection;
|
|
96
|
+
private globalAt;
|
|
97
|
+
private posAt;
|
|
98
|
+
/** A DOM point for a model position; at a format boundary it stays with the text before it. */
|
|
99
|
+
private domPoint;
|
|
100
|
+
private restoreSelection;
|
|
101
|
+
private focusAt;
|
|
102
|
+
private pendingApplies;
|
|
103
|
+
private listen;
|
|
104
|
+
private onFocus;
|
|
105
|
+
private onSelectionChange;
|
|
106
|
+
private onKeyDown;
|
|
107
|
+
private onBeforeInput;
|
|
108
|
+
private onInput;
|
|
109
|
+
private onCompositionStart;
|
|
110
|
+
private onCompositionEnd;
|
|
111
|
+
private fragmentFrom;
|
|
112
|
+
private onPaste;
|
|
113
|
+
private writeClipboard;
|
|
114
|
+
private onCopy;
|
|
115
|
+
private onDragStart;
|
|
116
|
+
private onDrop;
|
|
117
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour of a `contenteditable` rich-text field, shared by the React
|
|
3
|
+
* and Vue `TextAreaWithToolbar` so both behave identically. No `document.execCommand`:
|
|
4
|
+
* formatting is applied to the document model (./model.ts) and the editor
|
|
5
|
+
* re-renders from it, so the output is always the sanitized allow-list.
|
|
6
|
+
*
|
|
7
|
+
* Plain typing inside one paragraph stays native (spell-check, autocorrect,
|
|
8
|
+
* IME); everything that would let the browser invent markup (line breaks,
|
|
9
|
+
* joining paragraphs, deleting across them, paste, drop, its own format
|
|
10
|
+
* commands) goes through the model. Native undo cannot see model edits, so
|
|
11
|
+
* the editor keeps its own history and answers Ctrl/Cmd+Z, Ctrl+Y and
|
|
12
|
+
* Ctrl/Cmd+Shift+Z (plus the browser's Undo / Redo menu items).
|
|
13
|
+
*
|
|
14
|
+
* Quote and indent are paragraph formats: Ctrl/Cmd+] indents, Ctrl/Cmd+[
|
|
15
|
+
* outdents and Ctrl/Cmd+Shift+9 toggles the quote (when `blockShortcuts`
|
|
16
|
+
* allows), and Backspace at the start of an indented or quoted paragraph
|
|
17
|
+
* removes the indent, then the quote, before it joins the paragraph above.
|
|
18
|
+
* Tab is never captured: it keeps moving focus, as in any form field.
|
|
19
|
+
*/
|
|
20
|
+
import { type RichTextAlign, type RichTextFormat, type RichTextMark, type RichTextMarkPatch, type RichTextStyle } from './model.js';
|
|
21
|
+
export interface RichTextEditorOptions {
|
|
22
|
+
/** Fires when the sanitized value changes through editing (not through `setValue`). */
|
|
23
|
+
onChange?: (value: string, text: string) => void;
|
|
24
|
+
/** Fires when the format at the selection changes (drives the toolbar). */
|
|
25
|
+
onFormatChange?: (format: RichTextFormat) => void;
|
|
26
|
+
/** Formatting for text typed before the caret is first moved (e.g. start in bold). */
|
|
27
|
+
defaultMarks?: RichTextMarkPatch | null | undefined;
|
|
28
|
+
/** Whether the quote / indent shortcuts are on (e.g. only while their buttons are shown). */
|
|
29
|
+
blockShortcuts?: () => boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface RichTextCommandOptions {
|
|
32
|
+
/** Move focus back into the editor afterwards (pointer use of a toolbar button). */
|
|
33
|
+
focus?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare class RichTextEditor {
|
|
36
|
+
readonly root: HTMLElement;
|
|
37
|
+
private readonly options;
|
|
38
|
+
private doc;
|
|
39
|
+
private items;
|
|
40
|
+
private value;
|
|
41
|
+
private lastSelection;
|
|
42
|
+
private pending;
|
|
43
|
+
/** Caret the pending marks belong to; `null` until the caret is first placed. */
|
|
44
|
+
private pendingAt;
|
|
45
|
+
private history;
|
|
46
|
+
private historyIndex;
|
|
47
|
+
private lastEdit;
|
|
48
|
+
private composing;
|
|
49
|
+
private compositionStart;
|
|
50
|
+
private dragRange;
|
|
51
|
+
/** The DOM was re-rendered while focus was elsewhere, so its selection is gone. */
|
|
52
|
+
private selectionStale;
|
|
53
|
+
private formatKey;
|
|
54
|
+
private readonly cleanup;
|
|
55
|
+
constructor(root: HTMLElement, options?: RichTextEditorOptions);
|
|
56
|
+
destroy(): void;
|
|
57
|
+
/** The sanitized HTML value (`''` when empty). */
|
|
58
|
+
getValue(): string;
|
|
59
|
+
getText(): string;
|
|
60
|
+
getFormat(): RichTextFormat;
|
|
61
|
+
/** Replaces the content from outside (a controlled value). Does not fire `onChange`. */
|
|
62
|
+
setValue(html: string): void;
|
|
63
|
+
/** Bold / italic / underline: toggles the selection, or the typing format at a caret. */
|
|
64
|
+
toggleMark(mark: RichTextMark, options?: RichTextCommandOptions): void;
|
|
65
|
+
/** Colour, font family or size for the selection (or the typing format); `null` clears it. */
|
|
66
|
+
applyStyle(style: RichTextStyle, value: string | null, options?: RichTextCommandOptions): void;
|
|
67
|
+
/** Aligns every paragraph the selection touches (the caret's paragraph when collapsed). */
|
|
68
|
+
setAlign(align: RichTextAlign, options?: RichTextCommandOptions): void;
|
|
69
|
+
/** Quotes (or unquotes) every paragraph the selection touches. */
|
|
70
|
+
setQuote(on: boolean, options?: RichTextCommandOptions): void;
|
|
71
|
+
/** Sets the indent level (0 = none) of every paragraph the selection touches. */
|
|
72
|
+
setIndent(level: number | ((current: number) => number), options?: RichTextCommandOptions): void;
|
|
73
|
+
focus(): void;
|
|
74
|
+
undo(): void;
|
|
75
|
+
redo(): void;
|
|
76
|
+
private applyPatch;
|
|
77
|
+
private replace;
|
|
78
|
+
private insertText;
|
|
79
|
+
private afterModelEdit;
|
|
80
|
+
/** Publishes a changed value and records it in the history. */
|
|
81
|
+
private commit;
|
|
82
|
+
private record;
|
|
83
|
+
private stepHistory;
|
|
84
|
+
private emitFormat;
|
|
85
|
+
private render;
|
|
86
|
+
/** Re-reads the DOM after native editing; rewrites it if the browser left non-canonical markup. */
|
|
87
|
+
private syncFromDom;
|
|
88
|
+
private isFocused;
|
|
89
|
+
private endSelection;
|
|
90
|
+
/**
|
|
91
|
+
* The live selection while the editor has focus, else the last one it had
|
|
92
|
+
* (focus on a toolbar button leaves the document selection behind).
|
|
93
|
+
*/
|
|
94
|
+
private currentSelection;
|
|
95
|
+
private domSelection;
|
|
96
|
+
private globalAt;
|
|
97
|
+
private posAt;
|
|
98
|
+
/** A DOM point for a model position; at a format boundary it stays with the text before it. */
|
|
99
|
+
private domPoint;
|
|
100
|
+
private restoreSelection;
|
|
101
|
+
private focusAt;
|
|
102
|
+
private pendingApplies;
|
|
103
|
+
private listen;
|
|
104
|
+
private onFocus;
|
|
105
|
+
private onSelectionChange;
|
|
106
|
+
private onKeyDown;
|
|
107
|
+
private onBeforeInput;
|
|
108
|
+
private onInput;
|
|
109
|
+
private onCompositionStart;
|
|
110
|
+
private onCompositionEnd;
|
|
111
|
+
private fragmentFrom;
|
|
112
|
+
private onPaste;
|
|
113
|
+
private writeClipboard;
|
|
114
|
+
private onCopy;
|
|
115
|
+
private onDragStart;
|
|
116
|
+
private onDrop;
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=editor.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich-text document model shared by the React and Vue `TextAreaWithToolbar`.
|
|
3
|
+
*
|
|
4
|
+
* Every HTML string that enters the editor (a `value`, a paste, a drop, the
|
|
5
|
+
* live DOM after native typing) is read into this model and written back out
|
|
6
|
+
* by `serializeRichText`. Output is only ever produced from the model, so the
|
|
7
|
+
* allow-list is structural: text is escaped, and the only markup that can come
|
|
8
|
+
* out is `<blockquote>` around quoted paragraphs, `<p>` (optional `text-align`
|
|
9
|
+
* and a 24px `margin-left` indent), `<br>`, `<strong>`, `<em>`, `<u>` and
|
|
10
|
+
* `<span>` with validated `color` / `font-family` / `font-size`. Scripts,
|
|
11
|
+
* event handlers, URLs and every other tag or style are dropped on the way in.
|
|
12
|
+
*/
|
|
13
|
+
export type RichTextMark = 'bold' | 'italic' | 'underline';
|
|
14
|
+
export type RichTextStyle = 'color' | 'fontFamily' | 'fontSize';
|
|
15
|
+
export type RichTextAlign = 'left' | 'center' | 'right';
|
|
16
|
+
/** Formatting carried by a run of text. Absent = off / inherited default. */
|
|
17
|
+
export interface RichTextMarks {
|
|
18
|
+
bold?: true;
|
|
19
|
+
italic?: true;
|
|
20
|
+
underline?: true;
|
|
21
|
+
color?: string;
|
|
22
|
+
fontFamily?: string;
|
|
23
|
+
fontSize?: string;
|
|
24
|
+
}
|
|
25
|
+
/** A change to marks: `false` / `null` removes a mark or style. */
|
|
26
|
+
export interface RichTextMarkPatch {
|
|
27
|
+
bold?: boolean;
|
|
28
|
+
italic?: boolean;
|
|
29
|
+
underline?: boolean;
|
|
30
|
+
color?: string | null;
|
|
31
|
+
fontFamily?: string | null;
|
|
32
|
+
fontSize?: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface RichTextRun {
|
|
35
|
+
/** Text; `\n` is a line break (`<br>`). */
|
|
36
|
+
text: string;
|
|
37
|
+
marks: RichTextMarks;
|
|
38
|
+
}
|
|
39
|
+
export interface RichTextBlock {
|
|
40
|
+
align: RichTextAlign;
|
|
41
|
+
runs: RichTextRun[];
|
|
42
|
+
/** Inside a block quote. Absent = not quoted. */
|
|
43
|
+
quote?: true;
|
|
44
|
+
/** Indent steps of {@link RICH_TEXT_INDENT_STEP}px (0 to {@link RICH_TEXT_MAX_INDENT}). Absent = 0. */
|
|
45
|
+
indent?: number;
|
|
46
|
+
}
|
|
47
|
+
/** One indent step, in px (`spacing.24`). */
|
|
48
|
+
export declare const RICH_TEXT_INDENT_STEP = 24;
|
|
49
|
+
/** Deepest indent the editor produces; the model keeps a number so more levels can follow. */
|
|
50
|
+
export declare const RICH_TEXT_MAX_INDENT = 1;
|
|
51
|
+
export type RichTextDoc = RichTextBlock[];
|
|
52
|
+
/** A caret position: block index + character offset inside the block. */
|
|
53
|
+
export interface RichTextPos {
|
|
54
|
+
block: number;
|
|
55
|
+
offset: number;
|
|
56
|
+
}
|
|
57
|
+
/** What the toolbar shows for the current selection. */
|
|
58
|
+
export interface RichTextFormat {
|
|
59
|
+
bold: boolean;
|
|
60
|
+
italic: boolean;
|
|
61
|
+
underline: boolean;
|
|
62
|
+
align: RichTextAlign;
|
|
63
|
+
/** Every paragraph the selection touches is quoted. */
|
|
64
|
+
quote: boolean;
|
|
65
|
+
/** The smallest indent among the paragraphs the selection touches. */
|
|
66
|
+
indent: number;
|
|
67
|
+
/** `undefined` when the selection has no explicit value or mixes several. */
|
|
68
|
+
color: string | undefined;
|
|
69
|
+
fontFamily: string | undefined;
|
|
70
|
+
fontSize: string | undefined;
|
|
71
|
+
}
|
|
72
|
+
/** A CSS colour from the allow-list (hex, rgb[a](), a bare keyword), normalised, or `undefined`. */
|
|
73
|
+
export declare function cleanRichTextColor(value: string): string | undefined;
|
|
74
|
+
/** A font-family list made only of names, quotes, commas and hyphens, or `undefined`. */
|
|
75
|
+
export declare function cleanRichTextFontFamily(value: string): string | undefined;
|
|
76
|
+
/** A font size with a unit (px, pt, em, rem, %) inside sane bounds, or `undefined`. */
|
|
77
|
+
export declare function cleanRichTextFontSize(value: string): string | undefined;
|
|
78
|
+
export declare function applyMarkPatch(marks: RichTextMarks, patch: RichTextMarkPatch): RichTextMarks;
|
|
79
|
+
export interface RichTextReadOptions {
|
|
80
|
+
/**
|
|
81
|
+
* Clipboard / drag-and-drop content from elsewhere: keep only bold, italic,
|
|
82
|
+
* underline and paragraphs (foreign colours, fonts and sizes are dropped)
|
|
83
|
+
* and collapse source-code whitespace the way a browser renders it.
|
|
84
|
+
*/
|
|
85
|
+
foreign?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/** Where a DOM node landed in the model; used to map selections. */
|
|
88
|
+
export interface RichTextDomItem<N> {
|
|
89
|
+
node: N;
|
|
90
|
+
kind: 'text' | 'br' | 'placeholder' | 'block';
|
|
91
|
+
block: number;
|
|
92
|
+
start: number;
|
|
93
|
+
length: number;
|
|
94
|
+
}
|
|
95
|
+
/** Merges equal neighbouring runs and drops empty ones. */
|
|
96
|
+
export declare function normalizeDoc(doc: RichTextDoc): RichTextDoc;
|
|
97
|
+
/** Reads an HTML string (a value, or clipboard HTML with `foreign`) into the model. */
|
|
98
|
+
export declare function parseRichText(html: string, options?: RichTextReadOptions): RichTextDoc;
|
|
99
|
+
/** Reads the live editor DOM into the model, with where each node landed. */
|
|
100
|
+
export declare function readRichTextDom(root: Node): {
|
|
101
|
+
doc: RichTextDoc;
|
|
102
|
+
items: RichTextDomItem<Node>[];
|
|
103
|
+
};
|
|
104
|
+
/** Builds output nodes of any kind (an HTML string, framework vnodes) from the model. */
|
|
105
|
+
export interface RichTextRenderer<T> {
|
|
106
|
+
element(tag: RichTextTag, style: string | undefined, children: T[]): T;
|
|
107
|
+
text(text: string): T;
|
|
108
|
+
}
|
|
109
|
+
export type RichTextTag = 'blockquote' | 'p' | 'br' | 'strong' | 'em' | 'u' | 'span';
|
|
110
|
+
/**
|
|
111
|
+
* The editor's paragraphs as renderer nodes: always at least one (empty)
|
|
112
|
+
* paragraph. An empty line needs a <br> to have height; so does a paragraph
|
|
113
|
+
* ending in a line break. Neighbouring quoted paragraphs share one
|
|
114
|
+
* `<blockquote>`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function renderRichText<T>(doc: RichTextDoc, r: RichTextRenderer<T>): T[];
|
|
117
|
+
/** The markup the editor renders: always at least one (empty) paragraph. */
|
|
118
|
+
export declare function serializeRichText(doc: RichTextDoc): string;
|
|
119
|
+
/** The public value: the serialized document, or `''` when there is no content. */
|
|
120
|
+
export declare function richTextValue(doc: RichTextDoc): string;
|
|
121
|
+
export declare function isRichTextEmpty(doc: RichTextDoc): boolean;
|
|
122
|
+
/** Sanitizes an HTML string to the editor's allow-list. */
|
|
123
|
+
export declare function sanitizeRichText(html: string, options?: RichTextReadOptions): string;
|
|
124
|
+
/** Plain text of an HTML value: one line per paragraph, `<br>` as a newline. */
|
|
125
|
+
export declare function richTextToPlainText(html: string): string;
|
|
126
|
+
export declare function richTextDocText(doc: RichTextDoc): string;
|
|
127
|
+
export declare const emptyBlock: (align?: RichTextAlign) => RichTextBlock;
|
|
128
|
+
export declare function blockText(b: RichTextBlock): string;
|
|
129
|
+
export declare function blockLength(b: RichTextBlock | undefined): number;
|
|
130
|
+
/** Positions as one number: block boundaries count as one character. */
|
|
131
|
+
export declare function toGlobal(doc: RichTextDoc, pos: RichTextPos): number;
|
|
132
|
+
export declare function fromGlobal(doc: RichTextDoc, g: number): RichTextPos;
|
|
133
|
+
export declare function docLength(doc: RichTextDoc): number;
|
|
134
|
+
/** The content between two global positions, as a document fragment. */
|
|
135
|
+
export declare function sliceDoc(doc: RichTextDoc, from: number, to: number): RichTextDoc;
|
|
136
|
+
/**
|
|
137
|
+
* Replaces the range with a fragment. A one-block fragment goes inline; more
|
|
138
|
+
* blocks split the paragraph. Returns the new document and the caret after
|
|
139
|
+
* the inserted content.
|
|
140
|
+
*/
|
|
141
|
+
export declare function replaceRange(doc: RichTextDoc, from: number, to: number, fragment: RichTextDoc): {
|
|
142
|
+
doc: RichTextDoc;
|
|
143
|
+
caret: number;
|
|
144
|
+
};
|
|
145
|
+
/** Applies `change` to the marks of every character in the range. */
|
|
146
|
+
export declare function formatRange(doc: RichTextDoc, from: number, to: number, change: (marks: RichTextMarks) => RichTextMarks): RichTextDoc;
|
|
147
|
+
/** Sets the alignment of every block the range touches. */
|
|
148
|
+
export declare function alignRange(doc: RichTextDoc, from: number, to: number, align: RichTextAlign): RichTextDoc;
|
|
149
|
+
/** Quotes (or unquotes) every block the range touches. */
|
|
150
|
+
export declare function quoteRange(doc: RichTextDoc, from: number, to: number, on: boolean): RichTextDoc;
|
|
151
|
+
/**
|
|
152
|
+
* Sets the indent of every block the range touches, to `level` or to what
|
|
153
|
+
* `level(current)` returns; clamped to 0…{@link RICH_TEXT_MAX_INDENT}.
|
|
154
|
+
*/
|
|
155
|
+
export declare function indentRange(doc: RichTextDoc, from: number, to: number, level: number | ((current: number) => number)): RichTextDoc;
|
|
156
|
+
/** Marks typed text would take at a caret: the character before it, else the first one. */
|
|
157
|
+
export declare function marksAt(doc: RichTextDoc, g: number): RichTextMarks;
|
|
158
|
+
/** The format of a selection: a mark is on only if every selected character has it. */
|
|
159
|
+
export declare function formatOf(doc: RichTextDoc, anchor: number, focus: number, pending?: RichTextMarkPatch | null): RichTextFormat;
|
|
160
|
+
/** The toolbar format for a value before the editor is mounted: the caret at the end. */
|
|
161
|
+
export declare function richTextFormat(html: string, pending?: RichTextMarkPatch | null): RichTextFormat;
|