@threadlabs/looma 0.29.12 → 0.29.13
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/EXPRESSIVE-MARKDOWN.md +90 -0
- package/README.md +6 -0
- package/components/ui-tabs/ui-tabs.html +2 -2
- package/components/ui-tabs/ui-tabs.js +14 -10
- package/dist/index.js +1 -1
- package/editor/chunks/extensions.js +80 -251
- package/editor/chunks/markdown.js +242 -0
- package/editor/expressmark.d.ts +31 -0
- package/editor/expressmark.js +942 -0
- package/editor/extensions/index.js +2 -1
- package/editor/extensions/table-of-contents.d.ts +1 -1
- package/editor/index.js +2 -1
- package/editor/node-views/table-of-contents.d.ts +1 -1
- package/package.json +10 -3
- package/vue/UiTabs.vue +2 -2
- package/vue/chunks/UiTabs.js +3 -3
- package/vue/editor/index.js +42 -6
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Expressive Markdown
|
|
2
|
+
|
|
3
|
+
Convert a saved LoomaEditor document into readable Markdown or an independent
|
|
4
|
+
JSON AST. The adapter runs in browsers and on servers without creating an editor
|
|
5
|
+
or loading a DOM.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { editorDocumentToMarkdown, markdownToEditorDocument } from "@threadlabs/looma/editor/expressmark";
|
|
9
|
+
|
|
10
|
+
const markdown = editorDocumentToMarkdown(savedEditorJson);
|
|
11
|
+
const restoredEditorJson = markdownToEditorDocument(markdown);
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Use the AST when attaching a document title or building another renderer:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import {
|
|
18
|
+
editorDocumentToExpressmark,
|
|
19
|
+
expressmarkToEditorDocument,
|
|
20
|
+
} from "@threadlabs/looma/editor/expressmark";
|
|
21
|
+
import { parse, stringify } from "expressmark";
|
|
22
|
+
|
|
23
|
+
const ast = editorDocumentToExpressmark(savedEditorJson);
|
|
24
|
+
const markdown = stringify(ast);
|
|
25
|
+
const restoredEditorJson = expressmarkToEditorDocument(parse(markdown));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The editor continues to save Tiptap JSON. Expressive Markdown is an interchange
|
|
29
|
+
format; it does not replace your saved document, history or application metadata.
|
|
30
|
+
See the [proposal and syntax](https://nextwebwg.org/expressive-markdown/) and
|
|
31
|
+
[document AST](https://nextwebwg.org/expressive-markdown/ast).
|
|
32
|
+
|
|
33
|
+
## Conversion behavior
|
|
34
|
+
|
|
35
|
+
Headings, text formatting, links, images, lists, tasks, quotes, code, callouts,
|
|
36
|
+
disclosures, figures, tabs, chips, mentions and tables map to their semantic AST
|
|
37
|
+
nodes. Mention targets become `ref`; chips keep their `tone`; merged table cells
|
|
38
|
+
keep numeric spans and block bodies.
|
|
39
|
+
|
|
40
|
+
Table cell presentation uses string attributes: `textAlign`, `backgroundColor`
|
|
41
|
+
and the adapter profile's `columnWidths` (comma-separated pixel widths, such as
|
|
42
|
+
`"100,120"`). Image dimensions use string `width`/`height`; `responsive` is a
|
|
43
|
+
boolean. Tabs preserve `group`, `default`, `queryString`, `lazy` and each tab's
|
|
44
|
+
selection `value`. A tab's label is independent of that value.
|
|
45
|
+
|
|
46
|
+
Saved code fence titles, highlighted ranges and line-number choices become the
|
|
47
|
+
shared `title`, `highlight` and `showLineNumbers` fields. Ranges remain strings;
|
|
48
|
+
unknown fence metadata stays in `meta`. Code notation comments remain literal,
|
|
49
|
+
including escaped markers, as they do in the default editor.
|
|
50
|
+
|
|
51
|
+
Automatic tables of contents receive readable heading links on export. Headings
|
|
52
|
+
that need an anchor receive one in the exported AST; saved editor JSON is not
|
|
53
|
+
changed. Authored IDs remain unchanged, including duplicates. Pass
|
|
54
|
+
`{ onDiagnostic }` as the optional second argument to receive duplicate-ID warnings. Imported
|
|
55
|
+
TOC bodies remain authored navigation; edits to document headings do not replace
|
|
56
|
+
them. TOCs created with the editor's existing command continue to update from
|
|
57
|
+
headings automatically.
|
|
58
|
+
|
|
59
|
+
## Handle unsupported content
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { EditorMarkdownConversionError } from "@threadlabs/looma/editor/expressmark";
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const ast = editorDocumentToExpressmark(savedEditorJson);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof EditorMarkdownConversionError) {
|
|
68
|
+
console.error(error.path, error.feature);
|
|
69
|
+
} else {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Unsupported nodes, marks, attributes and meanings stop conversion with their
|
|
76
|
+
location. Keep the original saved JSON when preparing portable exports. The
|
|
77
|
+
adapter never mutates it or claims an unknown extension has been preserved in
|
|
78
|
+
Markdown. Native URLs and table background colors must pass the shared safe HTML
|
|
79
|
+
profile before the editor interprets them; unsupported values raise an error.
|
|
80
|
+
|
|
81
|
+
The editor content model has no document-level title or arbitrary metadata.
|
|
82
|
+
Store those fields alongside the editor JSON; importing a root that contains
|
|
83
|
+
them directly into editor content raises an error. Likewise, custom semantic
|
|
84
|
+
types, IDs on nodes other than headings, formatted callout/tab titles and
|
|
85
|
+
unsupported presentation must be handled by your application rather than
|
|
86
|
+
silently flattened.
|
|
87
|
+
|
|
88
|
+
The existing `loomaMarkdownFromDoc` and `loomaMarkdownToHtml` APIs retain their
|
|
89
|
+
Looma Markdown syntax. Choose the Expressive adapter explicitly for new portable
|
|
90
|
+
Markdown export.
|
package/README.md
CHANGED
|
@@ -28,6 +28,12 @@ import { getDefaultEditorExtensions } from "@threadlabs/looma/editor/extensions"
|
|
|
28
28
|
The editor components' event and geometry types, without Tiptap, are in
|
|
29
29
|
`@threadlabs/looma/editor/ui`.
|
|
30
30
|
|
|
31
|
+
## Expressive Markdown
|
|
32
|
+
|
|
33
|
+
Convert saved editor JSON with the SSR-safe `@threadlabs/looma/editor/expressmark`
|
|
34
|
+
adapter. It preserves supported semantic content and reports unsupported data
|
|
35
|
+
instead of silently flattening it. See [conversion examples and coverage](EXPRESSIVE-MARKDOWN.md).
|
|
36
|
+
|
|
31
37
|
## HTML
|
|
32
38
|
|
|
33
39
|
```html
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<prop name="label" type="string" default="Tabs"
|
|
6
6
|
>The tab list's accessible name, announced when focus enters the list. Name what the tabs switch between.</prop>
|
|
7
7
|
<prop name="value" type="string" default=""
|
|
8
|
-
>The id of the selected panel, initially or when set externally; empty selects the first panel. Choosing a tab updates it from there and reports a select
|
|
8
|
+
>The id or authored data-value of the selected panel, initially or when set externally; empty selects the first panel. Choosing a tab updates it from there and reports a select
|
|
9
9
|
event. A panel without an id is given a generated one.</prop>
|
|
10
10
|
<prop name="orientation" type="keyword" values="horizontal, vertical" default="horizontal"
|
|
11
11
|
>horizontal puts the tabs in a row above the panels, moved between with the Left and Right arrow keys; vertical puts them in a column beside the panels,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
displaying a panel takes time.</prop>
|
|
16
16
|
<prop name="stretch" type="boolean" default="false">Share the full width of the tab list equally between tabs instead of sizing each to its label.</prop>
|
|
17
17
|
<prop name="group" type="string" default=""
|
|
18
|
-
>Tabs with the same nonempty group share one selection: choosing a tab selects the tab with the same label in every other Tabs of that group in the
|
|
18
|
+
>Tabs with the same nonempty group share one selection: choosing a tab selects the tab with the same data-value (or label when no value is authored) in every other Tabs of that group in the
|
|
19
19
|
document, and the choice is remembered for the next visit. Use it for alternatives a reader picks once, such as a package manager or language.</prop>
|
|
20
20
|
<state type="string" name="internalValue" value=""></state>
|
|
21
21
|
<state name="tabs" type="list(object({ id: string, label: string }))" value="[]"></state>
|
|
@@ -17,6 +17,10 @@ function connect(host) {
|
|
|
17
17
|
const { list, panels: container } = host.refs;
|
|
18
18
|
const [trigger, stopTracking] = trackTrigger(host);
|
|
19
19
|
const panels = () => Array.from(container.children).filter((child) => child instanceof HTMLElement);
|
|
20
|
+
const panelKey = (panel) => panel?.getAttribute("data-value")
|
|
21
|
+
?? (panel?.getAttribute("aria-label") || host.state.tabs.find(tab => tab.id === panel?.id)?.label);
|
|
22
|
+
const resolveValue = (value) => panels().find(panel => panel.id === value)?.id
|
|
23
|
+
?? panels().find(panel => panel.getAttribute("data-value") === value)?.id;
|
|
20
24
|
const tabButtons = () => Array.from(list.querySelectorAll("button"));
|
|
21
25
|
const focusTab = (button) => {
|
|
22
26
|
for (const tab of tabButtons()) tab.tabIndex = tab === button ? 0 : -1;
|
|
@@ -33,7 +37,7 @@ function connect(host) {
|
|
|
33
37
|
return { id: panel.id, label: panel.getAttribute("aria-label") || `Tab ${index + 1}` };
|
|
34
38
|
});
|
|
35
39
|
if (!items.some((panel) => panel.id === host.state.internalValue)) {
|
|
36
|
-
host.state.internalValue = external || items[0]?.id || "";
|
|
40
|
+
host.state.internalValue = resolveValue(external) || items[0]?.id || "";
|
|
37
41
|
}
|
|
38
42
|
syncButtons();
|
|
39
43
|
};
|
|
@@ -58,7 +62,7 @@ function connect(host) {
|
|
|
58
62
|
const value = String(host.props.value.value || "");
|
|
59
63
|
if (value !== external) {
|
|
60
64
|
external = value;
|
|
61
|
-
host.state.internalValue = value;
|
|
65
|
+
host.state.internalValue = resolveValue(value) || panels()[0]?.id || "";
|
|
62
66
|
}
|
|
63
67
|
const selected = host.state.internalValue;
|
|
64
68
|
for (const panel of panels()) panel.hidden = panel.id !== selected;
|
|
@@ -68,11 +72,11 @@ function connect(host) {
|
|
|
68
72
|
|
|
69
73
|
const group = () => String(host.props.group.value || "");
|
|
70
74
|
const document = host.element.ownerDocument;
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
75
|
-
host.state.internalValue =
|
|
75
|
+
// Authored selection values cross groups; labels remain the fallback for existing panels.
|
|
76
|
+
const selectGroupValue = (label) => {
|
|
77
|
+
const panel = panels().find(candidate => panelKey(candidate) === label);
|
|
78
|
+
if (!panel || panel.id === host.state.internalValue) return false;
|
|
79
|
+
host.state.internalValue = panel.id;
|
|
76
80
|
return true;
|
|
77
81
|
};
|
|
78
82
|
const select = (button, how) => {
|
|
@@ -80,7 +84,7 @@ function connect(host) {
|
|
|
80
84
|
host.state.internalValue = button.value;
|
|
81
85
|
host.dispatch("select", { value: button.value, previousValue, trigger: how });
|
|
82
86
|
const name = group();
|
|
83
|
-
const label =
|
|
87
|
+
const label = panelKey(panels().find(panel => panel.id === button.value));
|
|
84
88
|
if (!name || label === undefined) return;
|
|
85
89
|
writeStored(name, label);
|
|
86
90
|
document.dispatchEvent(new CustomEvent(GROUP_EVENT, { detail: { group: name, label, source: host.element } }));
|
|
@@ -89,7 +93,7 @@ function connect(host) {
|
|
|
89
93
|
const { group: name, label, source } = event.detail;
|
|
90
94
|
if (source === host.element || name !== group()) return;
|
|
91
95
|
const previousValue = host.state.internalValue || undefined;
|
|
92
|
-
if (
|
|
96
|
+
if (selectGroupValue(label)) host.dispatch("select", { value: host.state.internalValue, previousValue, trigger: "programmatic" });
|
|
93
97
|
};
|
|
94
98
|
const onClick = (event) => {
|
|
95
99
|
const button = event.target.closest?.('[role="tab"]');
|
|
@@ -138,7 +142,7 @@ function connect(host) {
|
|
|
138
142
|
// A remembered choice applies when the page does not choose a panel itself.
|
|
139
143
|
if (group() && !external) {
|
|
140
144
|
const stored = readStored(group());
|
|
141
|
-
if (stored !== null)
|
|
145
|
+
if (stored !== null) selectGroupValue(stored);
|
|
142
146
|
}
|
|
143
147
|
return () => {
|
|
144
148
|
document.removeEventListener(GROUP_EVENT, onGroupSelect);
|