@pantoken/tinymce 0.1.0
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 +21 -0
- package/dist/index.d.mts +187 -0
- package/dist/index.mjs +606 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danny Wahl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { CdnFile, CdnProvider } from "@pantoken/cdn";
|
|
2
|
+
import { LogoMeta, Product, logos, products } from "@pantoken/plugin-logos";
|
|
3
|
+
import { Editor } from "tinymce";
|
|
4
|
+
import { CssDocEntry, CssDocEntry as CssDocEntry$1 } from "@cssdoc/core";
|
|
5
|
+
//#region src/content-css.d.ts
|
|
6
|
+
/** Resolves a set of {@link CdnFile}s to URLs suitable for TinyMCE's `content_css` init option. */
|
|
7
|
+
declare function pantokenContentCssUrls(assets: readonly CdnFile[], provider?: string | CdnProvider): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Appends a `<link rel="stylesheet">` to the editor's content document `<head>` at runtime.
|
|
10
|
+
* Idempotent per URL — calling this twice with the same `url` is a no-op the second time.
|
|
11
|
+
*/
|
|
12
|
+
declare function injectContentStylesheet(editor: Editor, url: string): void;
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/types.d.ts
|
|
15
|
+
/** A starter page template offered by {@link createTemplatesPlugin}. */
|
|
16
|
+
interface StarterTemplate {
|
|
17
|
+
/** Label shown in the "Insert template" picker. */
|
|
18
|
+
title: string;
|
|
19
|
+
/** Raw HTML inserted (replacing the whole document, after a confirm) when chosen. */
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/plugins/templates.d.ts
|
|
24
|
+
/** Options for {@link createTemplatesPlugin}. */
|
|
25
|
+
interface TemplatesPluginOptions {
|
|
26
|
+
/** The starter templates offered in the "Insert template" picker. */
|
|
27
|
+
templates: readonly StarterTemplate[];
|
|
28
|
+
/** Called after a template is inserted (e.g. to refresh a live preview). */
|
|
29
|
+
onInsert?: (template: StarterTemplate) => void;
|
|
30
|
+
}
|
|
31
|
+
/** Builds the `tinymce.PluginManager.add` callback for the "Insert template" plugin. */
|
|
32
|
+
declare function createTemplatesPlugin(options: TemplatesPluginOptions): (editor: Editor) => {};
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/icons.d.ts
|
|
35
|
+
/**
|
|
36
|
+
* Icon with source attribution for tracking which package it comes from.
|
|
37
|
+
*/
|
|
38
|
+
interface TaggedIcon {
|
|
39
|
+
name: string;
|
|
40
|
+
source: "components" | "simple-icons";
|
|
41
|
+
description?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load and merge all available icons from both sources.
|
|
45
|
+
* Returns a sorted array tagged with the source package.
|
|
46
|
+
*
|
|
47
|
+
* NOTE: Component icon names are derived from the model's `-icon-*` modifier.
|
|
48
|
+
* This needs to be populated from either `docs/public/icon-manifest.json` (if consumable)
|
|
49
|
+
* or a manifest published alongside formats/components model.json.
|
|
50
|
+
* For now, only simple-icons are included.
|
|
51
|
+
*/
|
|
52
|
+
declare function loadAllIcons(): Promise<TaggedIcon[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Compute the CdnFile for an icon given its source.
|
|
55
|
+
* Returns `{ package, path }` for use with \@pantoken/cdn's buildFileUrl.
|
|
56
|
+
*/
|
|
57
|
+
declare function getIconCdnFile(icon: TaggedIcon): {
|
|
58
|
+
package: string;
|
|
59
|
+
path: string;
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/cssdoc/model.d.ts
|
|
63
|
+
/**
|
|
64
|
+
* Find a component/utility/custom entry by name.
|
|
65
|
+
*/
|
|
66
|
+
declare function findEntry(name: string): CssDocEntry$1 | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* List all components (kind: "component").
|
|
69
|
+
*/
|
|
70
|
+
declare function listComponents(): CssDocEntry$1[];
|
|
71
|
+
/**
|
|
72
|
+
* List all utilities (kind: "utility").
|
|
73
|
+
*/
|
|
74
|
+
declare function listUtilities(): CssDocEntry$1[];
|
|
75
|
+
/**
|
|
76
|
+
* Get all modifier suggestions for a component/utility, filtered by an optional prefix.
|
|
77
|
+
* Returns array of `{ name, prop, value?, description }` objects.
|
|
78
|
+
*/
|
|
79
|
+
declare function getModifierSuggestions(entryName: string, prefix?: string): Array<{
|
|
80
|
+
name: string;
|
|
81
|
+
prop: string;
|
|
82
|
+
value?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Validate a pantoken class token (e.g., "instui-button", "instui-button.-color-primary").
|
|
87
|
+
* Returns an array of validation errors (empty if valid).
|
|
88
|
+
*/
|
|
89
|
+
declare function validateClassToken(token: string): string[];
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/plugins/components.d.ts
|
|
92
|
+
/**
|
|
93
|
+
* Configuration options for the components picker plugin.
|
|
94
|
+
*/
|
|
95
|
+
interface ComponentsPickerOptions {
|
|
96
|
+
model: CssDocEntry[];
|
|
97
|
+
currentAssets: CdnFile[];
|
|
98
|
+
onMissingAsset?: (asset: CdnFile) => void;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Create the components picker plugin factory.
|
|
102
|
+
* Returns a function suitable for `tinymce.PluginManager.add()`.
|
|
103
|
+
*
|
|
104
|
+
* Usage:
|
|
105
|
+
* tinymce.PluginManager.add(
|
|
106
|
+
* "pantokenComponents",
|
|
107
|
+
* createComponentsPlugin(`{ model, currentAssets, onMissingAsset }`)
|
|
108
|
+
* );
|
|
109
|
+
*/
|
|
110
|
+
declare function createComponentsPlugin(options: ComponentsPickerOptions): (editor: Editor) => void;
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/plugins/icons.d.ts
|
|
113
|
+
/**
|
|
114
|
+
* Configuration options for the icons picker plugin.
|
|
115
|
+
*/
|
|
116
|
+
interface IconsPickerOptions {
|
|
117
|
+
icons: TaggedIcon[];
|
|
118
|
+
currentAssets: CdnFile[];
|
|
119
|
+
onMissingAsset?: (asset: CdnFile) => void;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Create the icons picker plugin factory.
|
|
123
|
+
* Returns a function suitable for `tinymce.PluginManager.add()`.
|
|
124
|
+
*/
|
|
125
|
+
declare function createIconsPlugin(options: IconsPickerOptions): (editor: Editor) => void;
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/plugins/logos.d.ts
|
|
128
|
+
/**
|
|
129
|
+
* Configuration options for the logos picker plugin.
|
|
130
|
+
*/
|
|
131
|
+
interface LogosPickerOptions {
|
|
132
|
+
logos: readonly LogoMeta[];
|
|
133
|
+
products: readonly Product[];
|
|
134
|
+
currentAssets: CdnFile[];
|
|
135
|
+
onMissingAsset?: (asset: CdnFile) => void;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Create the logos picker plugin factory.
|
|
139
|
+
* Returns a function suitable for `tinymce.PluginManager.add()`.
|
|
140
|
+
*/
|
|
141
|
+
declare function createLogosPlugin(options: LogosPickerOptions): (editor: Editor) => void;
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/codemirror/lint.d.ts
|
|
144
|
+
/**
|
|
145
|
+
* Configuration options for the pantoken HTML linter.
|
|
146
|
+
*/
|
|
147
|
+
interface PantokenLinterOptions {
|
|
148
|
+
model: CssDocEntry[];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Create a CodeMirror 6 linter extension for pantoken HTML.
|
|
152
|
+
* Validates class attributes containing `.instui-*` tokens.
|
|
153
|
+
*
|
|
154
|
+
* Usage:
|
|
155
|
+
* new EditorView(\{
|
|
156
|
+
* extensions: [
|
|
157
|
+
* basicSetup,
|
|
158
|
+
* html(),
|
|
159
|
+
* pantokenHtmlLinter(\{ model \}),
|
|
160
|
+
* ],
|
|
161
|
+
* \});
|
|
162
|
+
*/
|
|
163
|
+
declare function pantokenHtmlLinter(_options: PantokenLinterOptions): import("@codemirror/state").Extension;
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/codemirror/autocomplete.d.ts
|
|
166
|
+
/**
|
|
167
|
+
* Configuration options for the pantoken HTML autocomplete extension.
|
|
168
|
+
*/
|
|
169
|
+
interface AutocompleteOptions {
|
|
170
|
+
model: CssDocEntry[];
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Create a CodeMirror 6 autocomplete extension for pantoken HTML.
|
|
174
|
+
* Provides completions for component names and their modifiers.
|
|
175
|
+
*
|
|
176
|
+
* Usage:
|
|
177
|
+
* new EditorView(\{
|
|
178
|
+
* extensions: [
|
|
179
|
+
* basicSetup,
|
|
180
|
+
* html(),
|
|
181
|
+
* pantokenHtmlCompletion(\{ model \}),
|
|
182
|
+
* ],
|
|
183
|
+
* \});
|
|
184
|
+
*/
|
|
185
|
+
declare function pantokenHtmlCompletion(options: AutocompleteOptions): import("@codemirror/state").Extension;
|
|
186
|
+
//#endregion
|
|
187
|
+
export { type CssDocEntry, type LogoMeta, type Product, type TaggedIcon, createComponentsPlugin, createIconsPlugin, createLogosPlugin, createTemplatesPlugin, findEntry, getIconCdnFile, getModifierSuggestions, injectContentStylesheet, listComponents, listUtilities, loadAllIcons, logos, pantokenContentCssUrls, pantokenHtmlCompletion, pantokenHtmlLinter, products, validateClassToken };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { buildFileUrls } from "@pantoken/cdn";
|
|
2
|
+
import componentsModel from "@pantoken/components/model.json" with { type: "json" };
|
|
3
|
+
import customComponentsModel from "@pantoken/plugin-custom-components/model.json" with { type: "json" };
|
|
4
|
+
import simpleIconsManifest from "@pantoken/plugin-simple-icons/manifest.json" with { type: "json" };
|
|
5
|
+
import { logos, products } from "@pantoken/plugin-logos";
|
|
6
|
+
import { linter } from "@codemirror/lint";
|
|
7
|
+
import { autocompletion } from "@codemirror/autocomplete";
|
|
8
|
+
//#region src/content-css.ts
|
|
9
|
+
/**
|
|
10
|
+
* Wires pantoken's CDN-hosted CSS into TinyMCE: the initial `content_css` list (feature 1 — the
|
|
11
|
+
* WYSIWYG editing surface renders with real pantoken styles, not just a separate preview pane),
|
|
12
|
+
* plus a runtime primitive to add one more stylesheet after init (reused by the components/icons/
|
|
13
|
+
* logos pickers for feature 3's dynamic `@import` behavior — TinyMCE has no supported way to mutate
|
|
14
|
+
* `content_css` after `init()` runs, so a picker's "missing asset" instead injects a `<link>`
|
|
15
|
+
* directly into the editor's content document).
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/** Resolves a set of {@link CdnFile}s to URLs suitable for TinyMCE's `content_css` init option. */
|
|
20
|
+
function pantokenContentCssUrls(assets, provider) {
|
|
21
|
+
return buildFileUrls([...assets], provider);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Appends a `<link rel="stylesheet">` to the editor's content document `<head>` at runtime.
|
|
25
|
+
* Idempotent per URL — calling this twice with the same `url` is a no-op the second time.
|
|
26
|
+
*/
|
|
27
|
+
function injectContentStylesheet(editor, url) {
|
|
28
|
+
const head = editor.getDoc().head;
|
|
29
|
+
if (head.querySelector(`link[href="${url}"]`)) return;
|
|
30
|
+
const link = editor.getDoc().createElement("link");
|
|
31
|
+
link.rel = "stylesheet";
|
|
32
|
+
link.href = url;
|
|
33
|
+
head.append(link);
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/plugins/templates.ts
|
|
37
|
+
/** The toolbar button/menu item name registered by this plugin. */
|
|
38
|
+
const TEMPLATES_TOOLBAR_NAME = "pantokenTemplates";
|
|
39
|
+
/** Builds the `tinymce.PluginManager.add` callback for the "Insert template" plugin. */
|
|
40
|
+
function createTemplatesPlugin(options) {
|
|
41
|
+
const { templates, onInsert } = options;
|
|
42
|
+
return function pantokenTemplatesPlugin(editor) {
|
|
43
|
+
const openDialog = () => {
|
|
44
|
+
editor.windowManager.open({
|
|
45
|
+
title: "Insert template",
|
|
46
|
+
body: {
|
|
47
|
+
type: "panel",
|
|
48
|
+
items: [{
|
|
49
|
+
type: "selectbox",
|
|
50
|
+
name: "template",
|
|
51
|
+
label: "Starter template",
|
|
52
|
+
items: templates.map((t) => ({
|
|
53
|
+
value: t.title,
|
|
54
|
+
text: t.title
|
|
55
|
+
}))
|
|
56
|
+
}]
|
|
57
|
+
},
|
|
58
|
+
initialData: { template: templates[0]?.title ?? "" },
|
|
59
|
+
buttons: [{
|
|
60
|
+
type: "cancel",
|
|
61
|
+
text: "Cancel"
|
|
62
|
+
}, {
|
|
63
|
+
type: "submit",
|
|
64
|
+
text: "Insert",
|
|
65
|
+
primary: true
|
|
66
|
+
}],
|
|
67
|
+
onSubmit: (api) => {
|
|
68
|
+
const { template } = api.getData();
|
|
69
|
+
const chosen = templates.find((t) => t.title === template);
|
|
70
|
+
api.close();
|
|
71
|
+
if (!chosen) return;
|
|
72
|
+
editor.windowManager.confirm(`Replace the current content with the "${chosen.title}" template?`, (confirmed) => {
|
|
73
|
+
if (!confirmed) return;
|
|
74
|
+
editor.setContent(chosen.content);
|
|
75
|
+
onInsert?.(chosen);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
editor.ui.registry.addButton(TEMPLATES_TOOLBAR_NAME, {
|
|
81
|
+
text: "Insert template",
|
|
82
|
+
onAction: openDialog
|
|
83
|
+
});
|
|
84
|
+
editor.ui.registry.addMenuItem(TEMPLATES_TOOLBAR_NAME, {
|
|
85
|
+
text: "Insert template…",
|
|
86
|
+
onAction: openDialog
|
|
87
|
+
});
|
|
88
|
+
return {};
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/cssdoc/model.ts
|
|
93
|
+
const MERGED_MODEL = [...componentsModel, ...customComponentsModel];
|
|
94
|
+
const INDEX_BY_NAME = new Map(MERGED_MODEL.map((entry) => [entry.name, entry]));
|
|
95
|
+
/**
|
|
96
|
+
* Find a component/utility/custom entry by name.
|
|
97
|
+
*/
|
|
98
|
+
function findEntry(name) {
|
|
99
|
+
return INDEX_BY_NAME.get(name);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* List all components (kind: "component").
|
|
103
|
+
*/
|
|
104
|
+
function listComponents() {
|
|
105
|
+
return MERGED_MODEL.filter((e) => e.kind === "component");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* List all utilities (kind: "utility").
|
|
109
|
+
*/
|
|
110
|
+
function listUtilities() {
|
|
111
|
+
return MERGED_MODEL.filter((e) => e.kind === "utility");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get all modifier suggestions for a component/utility, filtered by an optional prefix.
|
|
115
|
+
* Returns array of `{ name, prop, value?, description }` objects.
|
|
116
|
+
*/
|
|
117
|
+
function getModifierSuggestions(entryName, prefix) {
|
|
118
|
+
const entry = findEntry(entryName);
|
|
119
|
+
if (!entry) return [];
|
|
120
|
+
const modifiers = entry.modifiers || [];
|
|
121
|
+
if (!prefix) return modifiers;
|
|
122
|
+
return modifiers.filter((m) => m.name.startsWith(prefix));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Validate a pantoken class token (e.g., "instui-button", "instui-button.-color-primary").
|
|
126
|
+
* Returns an array of validation errors (empty if valid).
|
|
127
|
+
*/
|
|
128
|
+
function validateClassToken(token) {
|
|
129
|
+
const errors = [];
|
|
130
|
+
if (!token.startsWith("instui-")) return ["Token must start with 'instui-'"];
|
|
131
|
+
const rest = token.slice(7);
|
|
132
|
+
const parts = rest.split("-");
|
|
133
|
+
if (parts.length === 0 || !parts[0]) return ["Incomplete component name after 'instui-'"];
|
|
134
|
+
const componentName = parts[0];
|
|
135
|
+
const entry = findEntry(componentName);
|
|
136
|
+
if (!entry) errors.push(`Unknown component/utility: '${componentName}'`);
|
|
137
|
+
if (entry && parts.length > 1) {
|
|
138
|
+
const modifierTokens = rest.slice(componentName.length + 1);
|
|
139
|
+
const modifierList = entry.modifiers || [];
|
|
140
|
+
for (const mod of modifierList) {
|
|
141
|
+
if (mod.pattern) continue;
|
|
142
|
+
const modSuffix = mod.name.slice(1);
|
|
143
|
+
if (!modifierTokens.includes(modSuffix)) continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return errors;
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/icons.ts
|
|
150
|
+
/**
|
|
151
|
+
* Merged icon list: \@pantoken/components built-in icons + \@pantoken/plugin-simple-icons brand icons.
|
|
152
|
+
* Each icon is tagged with its source package so the correct CdnFile can be computed on insert.
|
|
153
|
+
*
|
|
154
|
+
* \@module
|
|
155
|
+
*/
|
|
156
|
+
/**
|
|
157
|
+
* Load and merge all available icons from both sources.
|
|
158
|
+
* Returns a sorted array tagged with the source package.
|
|
159
|
+
*
|
|
160
|
+
* NOTE: Component icon names are derived from the model's `-icon-*` modifier.
|
|
161
|
+
* This needs to be populated from either `docs/public/icon-manifest.json` (if consumable)
|
|
162
|
+
* or a manifest published alongside formats/components model.json.
|
|
163
|
+
* For now, only simple-icons are included.
|
|
164
|
+
*/
|
|
165
|
+
async function loadAllIcons() {
|
|
166
|
+
const icons = [];
|
|
167
|
+
if (Array.isArray(simpleIconsManifest)) for (const iconSlug of simpleIconsManifest) icons.push({
|
|
168
|
+
name: iconSlug,
|
|
169
|
+
source: "simple-icons",
|
|
170
|
+
description: `Brand icon: ${iconSlug}`
|
|
171
|
+
});
|
|
172
|
+
return icons.sort((a, b) => a.name.localeCompare(b.name));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Compute the CdnFile for an icon given its source.
|
|
176
|
+
* Returns `{ package, path }` for use with \@pantoken/cdn's buildFileUrl.
|
|
177
|
+
*/
|
|
178
|
+
function getIconCdnFile(icon) {
|
|
179
|
+
switch (icon.source) {
|
|
180
|
+
case "components": return {
|
|
181
|
+
package: "@pantoken/components",
|
|
182
|
+
path: `dist/icons/${icon.name}.css`
|
|
183
|
+
};
|
|
184
|
+
case "simple-icons": return {
|
|
185
|
+
package: "@pantoken/plugin-simple-icons",
|
|
186
|
+
path: `dist/icons/${icon.name}.css`
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/plugins/components.ts
|
|
192
|
+
/**
|
|
193
|
+
* Create the components picker plugin factory.
|
|
194
|
+
* Returns a function suitable for `tinymce.PluginManager.add()`.
|
|
195
|
+
*
|
|
196
|
+
* Usage:
|
|
197
|
+
* tinymce.PluginManager.add(
|
|
198
|
+
* "pantokenComponents",
|
|
199
|
+
* createComponentsPlugin(`{ model, currentAssets, onMissingAsset }`)
|
|
200
|
+
* );
|
|
201
|
+
*/
|
|
202
|
+
function createComponentsPlugin(options) {
|
|
203
|
+
return (editor) => {
|
|
204
|
+
const componentList = buildComponentList(options.model);
|
|
205
|
+
editor.ui.registry.addButton("pantokenComponents", {
|
|
206
|
+
text: "Components",
|
|
207
|
+
tooltip: "Insert a pantoken component",
|
|
208
|
+
onAction: () => openComponentsDialog(editor, componentList, options)
|
|
209
|
+
});
|
|
210
|
+
editor.ui.registry.addMenuItem("pantokenComponents", {
|
|
211
|
+
text: "Component",
|
|
212
|
+
onAction: () => openComponentsDialog(editor, componentList, options)
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Build a searchable list of all components/utilities for the picker.
|
|
218
|
+
*/
|
|
219
|
+
function buildComponentList(model) {
|
|
220
|
+
const list = [];
|
|
221
|
+
for (const entry of model) list.push({
|
|
222
|
+
name: entry.name,
|
|
223
|
+
className: entry.className,
|
|
224
|
+
kind: entry.kind,
|
|
225
|
+
description: entry.description || "",
|
|
226
|
+
examples: entry.examples || []
|
|
227
|
+
});
|
|
228
|
+
return list.sort((a, b) => a.name.localeCompare(b.name));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Open the components picker dialog.
|
|
232
|
+
*/
|
|
233
|
+
function openComponentsDialog(editor, components, options) {
|
|
234
|
+
let filteredComponents = components;
|
|
235
|
+
editor.windowManager.open({
|
|
236
|
+
title: "Insert Component",
|
|
237
|
+
body: {
|
|
238
|
+
type: "panel",
|
|
239
|
+
items: [{
|
|
240
|
+
type: "input",
|
|
241
|
+
name: "search",
|
|
242
|
+
label: "Search",
|
|
243
|
+
placeholder: "e.g., button, card, form"
|
|
244
|
+
}, {
|
|
245
|
+
type: "listbox",
|
|
246
|
+
name: "component",
|
|
247
|
+
label: "Components",
|
|
248
|
+
items: filteredComponents.map((c) => ({
|
|
249
|
+
text: `${c.name} (${c.kind})`,
|
|
250
|
+
value: c.name
|
|
251
|
+
})),
|
|
252
|
+
size: 10
|
|
253
|
+
}]
|
|
254
|
+
},
|
|
255
|
+
buttons: [{
|
|
256
|
+
text: "Insert",
|
|
257
|
+
type: "submit",
|
|
258
|
+
primary: true
|
|
259
|
+
}, {
|
|
260
|
+
text: "Cancel",
|
|
261
|
+
type: "cancel"
|
|
262
|
+
}],
|
|
263
|
+
onSubmit: (api) => {
|
|
264
|
+
const selectedName = api.getData().component;
|
|
265
|
+
const component = components.find((c) => c.name === selectedName);
|
|
266
|
+
if (component && component.examples.length > 0) {
|
|
267
|
+
const html = component.examples[0];
|
|
268
|
+
editor.insertContent(html);
|
|
269
|
+
const cssFile = {
|
|
270
|
+
package: "@pantoken/components",
|
|
271
|
+
path: `dist/${component.name}.css`
|
|
272
|
+
};
|
|
273
|
+
if (!options.currentAssets.find((a) => a.path === cssFile.path)) {
|
|
274
|
+
options.currentAssets.push(cssFile);
|
|
275
|
+
if (options.onMissingAsset) options.onMissingAsset(cssFile);
|
|
276
|
+
}
|
|
277
|
+
injectContentStylesheet(editor, `https://unpkg.com/${cssFile.package}@latest/${cssFile.path}`);
|
|
278
|
+
}
|
|
279
|
+
api.close();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/plugins/icons.ts
|
|
285
|
+
const ICONS_PER_PAGE = 50;
|
|
286
|
+
/**
|
|
287
|
+
* Create the icons picker plugin factory.
|
|
288
|
+
* Returns a function suitable for `tinymce.PluginManager.add()`.
|
|
289
|
+
*/
|
|
290
|
+
function createIconsPlugin(options) {
|
|
291
|
+
return (editor) => {
|
|
292
|
+
editor.ui.registry.addButton("pantokenIcons", {
|
|
293
|
+
text: "Icons",
|
|
294
|
+
tooltip: "Insert an icon",
|
|
295
|
+
onAction: () => openIconsDialog(editor, options)
|
|
296
|
+
});
|
|
297
|
+
editor.ui.registry.addMenuItem("pantokenIcons", {
|
|
298
|
+
text: "Icon",
|
|
299
|
+
onAction: () => openIconsDialog(editor, options)
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Open the icons picker dialog.
|
|
305
|
+
*/
|
|
306
|
+
function openIconsDialog(editor, options) {
|
|
307
|
+
let currentPage = 0;
|
|
308
|
+
let filteredIcons = options.icons;
|
|
309
|
+
let selectedIcon;
|
|
310
|
+
editor.windowManager.open({
|
|
311
|
+
title: "Insert Icon",
|
|
312
|
+
body: {
|
|
313
|
+
type: "panel",
|
|
314
|
+
items: [{
|
|
315
|
+
type: "input",
|
|
316
|
+
name: "search",
|
|
317
|
+
label: "Search",
|
|
318
|
+
placeholder: "e.g., heart, star, menu",
|
|
319
|
+
onChange: (api) => {
|
|
320
|
+
const searchTerm = api.target.value.toLowerCase();
|
|
321
|
+
filteredIcons = options.icons.filter((icon) => icon.name.toLowerCase().includes(searchTerm));
|
|
322
|
+
currentPage = 0;
|
|
323
|
+
}
|
|
324
|
+
}, {
|
|
325
|
+
type: "htmlpanel",
|
|
326
|
+
html: renderIconGrid(filteredIcons, currentPage, ICONS_PER_PAGE, (icon) => {
|
|
327
|
+
selectedIcon = icon;
|
|
328
|
+
})
|
|
329
|
+
}]
|
|
330
|
+
},
|
|
331
|
+
buttons: [{
|
|
332
|
+
text: "Insert",
|
|
333
|
+
type: "submit",
|
|
334
|
+
primary: true,
|
|
335
|
+
disabled: !selectedIcon
|
|
336
|
+
}, {
|
|
337
|
+
text: "Cancel",
|
|
338
|
+
type: "cancel"
|
|
339
|
+
}],
|
|
340
|
+
onSubmit: (api) => {
|
|
341
|
+
if (selectedIcon) insertIcon(editor, selectedIcon, options);
|
|
342
|
+
api.close();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Render an HTML grid of icons for the current page.
|
|
348
|
+
*/
|
|
349
|
+
function renderIconGrid(icons, page, perPage, _onSelect) {
|
|
350
|
+
const start = page * perPage;
|
|
351
|
+
const end = start + perPage;
|
|
352
|
+
const pageIcons = icons.slice(start, end);
|
|
353
|
+
let html = "<div style=\"display: grid; grid-template-columns: repeat(10, 1fr); gap: 8px;\">";
|
|
354
|
+
for (const icon of pageIcons) html += `<button style="padding: 8px; cursor: pointer; font-size: 12px;" title="${icon.name}">${icon.name}</button>`;
|
|
355
|
+
html += "</div>";
|
|
356
|
+
const totalPages = Math.ceil(icons.length / perPage);
|
|
357
|
+
html += `<div style="margin-top: 8px; text-align: center; font-size: 12px;">Page ${page + 1} of ${totalPages} (${icons.length} icons)</div>`;
|
|
358
|
+
return html;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Insert the selected icon into the editor.
|
|
362
|
+
*/
|
|
363
|
+
function insertIcon(editor, icon, options) {
|
|
364
|
+
const html = generateIconHtml(icon);
|
|
365
|
+
editor.insertContent(html);
|
|
366
|
+
const cssFile = getIconCdnFile(icon);
|
|
367
|
+
if (!options.currentAssets.find((a) => a.path === cssFile.path)) {
|
|
368
|
+
options.currentAssets.push(cssFile);
|
|
369
|
+
if (options.onMissingAsset) options.onMissingAsset(cssFile);
|
|
370
|
+
}
|
|
371
|
+
injectContentStylesheet(editor, `https://unpkg.com/${cssFile.package}@latest/${cssFile.path}`);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Generate the HTML snippet for inserting an icon.
|
|
375
|
+
* For component icons, use an `<i class="instui-icon-...">` element.
|
|
376
|
+
* For simple-icons, use a similar convention with a class prefix.
|
|
377
|
+
*/
|
|
378
|
+
function generateIconHtml(icon) {
|
|
379
|
+
switch (icon.source) {
|
|
380
|
+
case "components": return `<i class="instui-icon -icon-${icon.name}"></i>`;
|
|
381
|
+
case "simple-icons": return `<i class="simple-icon-${icon.name}"></i>`;
|
|
382
|
+
default: return "";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/plugins/logos.ts
|
|
387
|
+
/**
|
|
388
|
+
* Create the logos picker plugin factory.
|
|
389
|
+
* Returns a function suitable for `tinymce.PluginManager.add()`.
|
|
390
|
+
*/
|
|
391
|
+
function createLogosPlugin(options) {
|
|
392
|
+
return (editor) => {
|
|
393
|
+
editor.ui.registry.addButton("pantokenLogos", {
|
|
394
|
+
text: "Logos",
|
|
395
|
+
tooltip: "Insert a logo",
|
|
396
|
+
onAction: () => openLogosDialog(editor, options)
|
|
397
|
+
});
|
|
398
|
+
editor.ui.registry.addMenuItem("pantokenLogos", {
|
|
399
|
+
text: "Logo",
|
|
400
|
+
onAction: () => openLogosDialog(editor, options)
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Open the logos picker dialog.
|
|
406
|
+
*/
|
|
407
|
+
function openLogosDialog(editor, options) {
|
|
408
|
+
const productItems = options.products.map((p) => ({
|
|
409
|
+
text: p,
|
|
410
|
+
value: p
|
|
411
|
+
}));
|
|
412
|
+
editor.windowManager.open({
|
|
413
|
+
title: "Insert Logo",
|
|
414
|
+
body: {
|
|
415
|
+
type: "panel",
|
|
416
|
+
items: [
|
|
417
|
+
{
|
|
418
|
+
type: "selectbox",
|
|
419
|
+
name: "product",
|
|
420
|
+
label: "Product",
|
|
421
|
+
items: productItems,
|
|
422
|
+
onChange: (_api) => {}
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
type: "selectbox",
|
|
426
|
+
name: "layout",
|
|
427
|
+
label: "Layout",
|
|
428
|
+
items: [
|
|
429
|
+
{
|
|
430
|
+
text: "Horizontal",
|
|
431
|
+
value: "horizontal"
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
text: "Vertical",
|
|
435
|
+
value: "vertical"
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
text: "Stacked",
|
|
439
|
+
value: "stacked"
|
|
440
|
+
}
|
|
441
|
+
],
|
|
442
|
+
onChange: (api) => {
|
|
443
|
+
api.target.value;
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
type: "selectbox",
|
|
448
|
+
name: "colorMode",
|
|
449
|
+
label: "Color Mode",
|
|
450
|
+
items: [
|
|
451
|
+
{
|
|
452
|
+
text: "Color",
|
|
453
|
+
value: "color"
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
text: "Monochrome",
|
|
457
|
+
value: "monochrome"
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
text: "Light",
|
|
461
|
+
value: "light"
|
|
462
|
+
}
|
|
463
|
+
],
|
|
464
|
+
onChange: (api) => {
|
|
465
|
+
api.target.value;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
},
|
|
470
|
+
buttons: [{
|
|
471
|
+
text: "Insert",
|
|
472
|
+
type: "submit",
|
|
473
|
+
primary: true,
|
|
474
|
+
disabled: true
|
|
475
|
+
}, {
|
|
476
|
+
text: "Cancel",
|
|
477
|
+
type: "cancel"
|
|
478
|
+
}],
|
|
479
|
+
onSubmit: (api) => {
|
|
480
|
+
api.close();
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/codemirror/lint.ts
|
|
486
|
+
/**
|
|
487
|
+
* CodeMirror HTML linter extension for pantoken tokens.
|
|
488
|
+
* Scans `class` attributes for `.instui-*` class names and validates them against the model.
|
|
489
|
+
* Reports diagnostics for unknown components, utilities, or invalid modifiers.
|
|
490
|
+
*
|
|
491
|
+
* \@module
|
|
492
|
+
*/
|
|
493
|
+
/**
|
|
494
|
+
* Create a CodeMirror 6 linter extension for pantoken HTML.
|
|
495
|
+
* Validates class attributes containing `.instui-*` tokens.
|
|
496
|
+
*
|
|
497
|
+
* Usage:
|
|
498
|
+
* new EditorView(\{
|
|
499
|
+
* extensions: [
|
|
500
|
+
* basicSetup,
|
|
501
|
+
* html(),
|
|
502
|
+
* pantokenHtmlLinter(\{ model \}),
|
|
503
|
+
* ],
|
|
504
|
+
* \});
|
|
505
|
+
*/
|
|
506
|
+
function pantokenHtmlLinter(_options) {
|
|
507
|
+
return linter((view) => {
|
|
508
|
+
const diagnostics = [];
|
|
509
|
+
const text = view.state.doc.toString();
|
|
510
|
+
const classAttributeRegex = /class=["']([^"']+)["']/g;
|
|
511
|
+
let match;
|
|
512
|
+
while ((match = classAttributeRegex.exec(text)) !== null) {
|
|
513
|
+
const classes = match[1].split(/\s+/).filter((c) => c.length > 0);
|
|
514
|
+
for (const className of classes) {
|
|
515
|
+
if (!className.startsWith("instui-")) continue;
|
|
516
|
+
const errors = validateClassToken(className);
|
|
517
|
+
for (const error of errors) {
|
|
518
|
+
const pos = text.indexOf(className, match.index);
|
|
519
|
+
if (pos !== -1) diagnostics.push({
|
|
520
|
+
from: pos,
|
|
521
|
+
to: pos + className.length,
|
|
522
|
+
severity: "error",
|
|
523
|
+
message: error
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return diagnostics;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/codemirror/autocomplete.ts
|
|
533
|
+
/**
|
|
534
|
+
* CodeMirror HTML autocomplete extension for pantoken tokens.
|
|
535
|
+
* Provides completions for component names, modifiers, and modifier values.
|
|
536
|
+
* Activates when typing `.instui-` in a class attribute.
|
|
537
|
+
*
|
|
538
|
+
* \@module
|
|
539
|
+
*/
|
|
540
|
+
/**
|
|
541
|
+
* Create a CodeMirror 6 autocomplete extension for pantoken HTML.
|
|
542
|
+
* Provides completions for component names and their modifiers.
|
|
543
|
+
*
|
|
544
|
+
* Usage:
|
|
545
|
+
* new EditorView(\{
|
|
546
|
+
* extensions: [
|
|
547
|
+
* basicSetup,
|
|
548
|
+
* html(),
|
|
549
|
+
* pantokenHtmlCompletion(\{ model \}),
|
|
550
|
+
* ],
|
|
551
|
+
* \});
|
|
552
|
+
*/
|
|
553
|
+
function pantokenHtmlCompletion(options) {
|
|
554
|
+
return autocompletion({ override: [(context) => {
|
|
555
|
+
const { state, pos } = context;
|
|
556
|
+
const line = state.doc.lineAt(pos);
|
|
557
|
+
const lineText = line.text;
|
|
558
|
+
const posInLine = pos - line.from;
|
|
559
|
+
const classMatch = /class=["']([^"']*)/g.exec(lineText);
|
|
560
|
+
if (!classMatch || posInLine < classMatch.index + classMatch[0].length) return null;
|
|
561
|
+
const classString = classMatch[1];
|
|
562
|
+
const lastSpace = classString.lastIndexOf(" ");
|
|
563
|
+
const partial = lastSpace === -1 ? classString : classString.slice(lastSpace + 1);
|
|
564
|
+
if (!partial.startsWith("instui-")) return null;
|
|
565
|
+
const completions = generateCompletions(partial, options.model);
|
|
566
|
+
return {
|
|
567
|
+
from: pos - partial.length,
|
|
568
|
+
options: completions
|
|
569
|
+
};
|
|
570
|
+
}] });
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Generate completion suggestions for a partial pantoken token.
|
|
574
|
+
* Handles three cases:
|
|
575
|
+
* 1. `instui-` → component/utility names
|
|
576
|
+
* 2. `instui-button-` → modifier suggestions for that component
|
|
577
|
+
* 3. `instui-button.-color-` → values for that modifier
|
|
578
|
+
*/
|
|
579
|
+
function generateCompletions(partial, _model) {
|
|
580
|
+
const completions = [];
|
|
581
|
+
if (!partial.includes("-", 7)) {
|
|
582
|
+
const prefix = partial.slice(7).toLowerCase();
|
|
583
|
+
const allItems = [...listComponents(), ...listUtilities()];
|
|
584
|
+
for (const item of allItems) if (item.name.toLowerCase().startsWith(prefix)) completions.push({
|
|
585
|
+
label: item.name,
|
|
586
|
+
detail: item.kind,
|
|
587
|
+
type: "class",
|
|
588
|
+
info: item.description || ""
|
|
589
|
+
});
|
|
590
|
+
return completions;
|
|
591
|
+
}
|
|
592
|
+
const componentName = partial.split("-")[1];
|
|
593
|
+
const mods = getModifierSuggestions(componentName);
|
|
594
|
+
for (const mod of mods) {
|
|
595
|
+
const modPrefix = mod.value ? `${mod.name}-`.split("-").slice(0, 2).join("-") : mod.name;
|
|
596
|
+
completions.push({
|
|
597
|
+
label: modPrefix,
|
|
598
|
+
detail: mod.prop || "modifier",
|
|
599
|
+
type: "class",
|
|
600
|
+
info: mod.description || ""
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return completions;
|
|
604
|
+
}
|
|
605
|
+
//#endregion
|
|
606
|
+
export { createComponentsPlugin, createIconsPlugin, createLogosPlugin, createTemplatesPlugin, findEntry, getIconCdnFile, getModifierSuggestions, injectContentStylesheet, listComponents, listUtilities, loadAllIcons, logos, pantokenContentCssUrls, pantokenHtmlCompletion, pantokenHtmlLinter, products, validateClassToken };
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pantoken/tinymce",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared TinyMCE + CodeMirror editor integration for pantoken: content-CSS wiring, an Insert Template plugin, components/icons/logos pickers with dynamic @import injection, and CodeMirror HTML lint/autocomplete driven by cssdoc.",
|
|
5
|
+
"homepage": "https://pantoken.iywahl.com",
|
|
6
|
+
"bugs": "https://github.com/thedannywahl/pantoken/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/thedannywahl/pantoken.git",
|
|
11
|
+
"directory": "renderers/tinymce"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./dist/index.mjs",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"provenance": true
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cssdoc/core": "^0.14.2",
|
|
28
|
+
"@pantoken/cdn": "0.1.0",
|
|
29
|
+
"@pantoken/components": "1.0.3",
|
|
30
|
+
"@pantoken/plugin-custom-components": "0.3.0",
|
|
31
|
+
"@pantoken/plugin-logos": "0.3.6",
|
|
32
|
+
"@pantoken/plugin-simple-icons": "0.3.6"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@codemirror/autocomplete": "^6.20.3",
|
|
36
|
+
"@codemirror/lang-html": "^6.4.12",
|
|
37
|
+
"@codemirror/lint": "^6.9.7",
|
|
38
|
+
"@codemirror/state": "^6.7.1",
|
|
39
|
+
"@codemirror/view": "^6.28.0",
|
|
40
|
+
"@types/node": "^26.4.0",
|
|
41
|
+
"codemirror": "^6.0.2",
|
|
42
|
+
"tinymce": "^5.9",
|
|
43
|
+
"typescript": "^7.0.2",
|
|
44
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
|
|
45
|
+
"vite-plus": "0.3.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@codemirror/autocomplete": "^6.20.3",
|
|
49
|
+
"@codemirror/lang-html": "^6.4.12",
|
|
50
|
+
"@codemirror/lint": "^6.9.7",
|
|
51
|
+
"@codemirror/state": "^6.7.1",
|
|
52
|
+
"@codemirror/view": "^6.0.0",
|
|
53
|
+
"codemirror": "^6.0.2",
|
|
54
|
+
"tinymce": "^5.9"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22.18.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"dev": "vp pack --watch",
|
|
61
|
+
"test": "vp test",
|
|
62
|
+
"check": "vp check"
|
|
63
|
+
}
|
|
64
|
+
}
|