@jackuait/blok 0.10.0-beta.9 → 0.10.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/dist/blok.mjs +2 -2
- package/dist/chunks/{blok-DDu252IK.mjs → blok-BfcBwAfE.mjs} +1211 -1159
- package/dist/chunks/{constants-DMW9a31I.mjs → constants-QNVyXALL.mjs} +49 -48
- package/dist/chunks/{tools-XmzH2rgQ.mjs → tools-DHtzbrxy.mjs} +1403 -1220
- package/dist/full.mjs +3 -3
- package/dist/react.mjs +2 -2
- package/dist/tools.mjs +2 -2
- package/package.json +3 -5
- package/src/cli/commands/convert-gdocs/index.ts +26 -0
- package/src/cli/commands/convert-html/block-builder.ts +392 -0
- package/src/cli/commands/convert-html/id-generator.ts +11 -0
- package/src/cli/commands/convert-html/index.ts +23 -0
- package/src/cli/commands/convert-html/preprocessor.ts +422 -0
- package/src/cli/commands/convert-html/sanitizer.ts +93 -0
- package/src/cli/commands/convert-html/types.ts +15 -0
- package/src/cli/index.ts +56 -5
- package/src/components/block/index.ts +44 -10
- package/src/components/constants/data-attributes.ts +10 -0
- package/src/components/icons/index.ts +16 -0
- package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +18 -0
- package/src/components/modules/blockManager/hierarchy.ts +4 -1
- package/src/components/modules/readonly.ts +46 -0
- package/src/components/modules/rectangleSelection.ts +25 -5
- package/src/components/modules/toolbar/index.ts +96 -19
- package/src/components/modules/toolbar/styles.ts +0 -2
- package/src/components/modules/uiControllers/controllers/blockHover.ts +44 -1
- package/src/components/tools/block.ts +10 -0
- package/src/components/utils/placeholder.ts +9 -2
- package/src/styles/main.css +16 -0
- package/src/tools/callout/constants.ts +2 -1
- package/src/tools/callout/dom-builder.ts +13 -1
- package/src/tools/callout/index.ts +21 -7
- package/src/tools/code/constants.ts +9 -1
- package/src/tools/code/dom-builder.ts +90 -54
- package/src/tools/code/index.ts +73 -31
- package/src/tools/divider/index.ts +5 -0
- package/src/tools/header/index.ts +47 -1
- package/src/tools/list/dom-builder.ts +3 -1
- package/src/tools/list/index.ts +55 -3
- package/src/tools/list/list-helpers.ts +2 -2
- package/src/tools/nested-blocks.ts +25 -0
- package/src/tools/paragraph/index.ts +47 -6
- package/src/tools/quote/index.ts +43 -8
- package/src/tools/stub/index.ts +10 -0
- package/src/tools/table/index.ts +238 -6
- package/src/tools/table/table-add-controls.ts +37 -5
- package/src/tools/table/table-cell-blocks.ts +57 -18
- package/src/tools/table/table-core.ts +2 -0
- package/src/tools/table/table-corner-drag.ts +247 -0
- package/src/tools/table/table-operations.ts +41 -14
- package/src/tools/toggle/dom-builder.ts +1 -0
- package/src/tools/toggle/index.ts +25 -0
- package/src/tools/toggle/toggle-lifecycle.ts +5 -4
- package/src/types-internal/jsdom.d.ts +9 -0
- package/types/tools/adapters/block-tool-adapter.d.ts +6 -0
- package/types/tools/block-tool.d.ts +10 -0
- package/bin/blok.mjs +0 -10
- package/dist/cli.mjs +0 -37
- package/src/tools/code/language-picker.ts +0 -241
|
@@ -35,6 +35,12 @@ interface BlockToolAdapter extends BaseToolAdapter<ToolType.Block, BlockTool>{
|
|
|
35
35
|
*/
|
|
36
36
|
isReadOnlySupported: boolean;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Returns true if the Tool's prototype has a setReadOnly method,
|
|
40
|
+
* enabling the in-place read-only toggle path (no save/clear/render cycle).
|
|
41
|
+
*/
|
|
42
|
+
supportsInPlaceReadOnly: boolean;
|
|
43
|
+
|
|
38
44
|
/**
|
|
39
45
|
* Returns true if Tool supports linebreaks
|
|
40
46
|
*/
|
|
@@ -87,6 +87,16 @@ export interface BlockTool extends BaseTool {
|
|
|
87
87
|
* @returns Object with left offset in pixels, or undefined if no offset should be applied
|
|
88
88
|
*/
|
|
89
89
|
getContentOffset?(hoveredElement: Element): { left: number } | undefined;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Called when read-only mode is toggled without re-rendering the block.
|
|
93
|
+
* Implementations should update the DOM in place: toggle contentEditable,
|
|
94
|
+
* bind/unbind event listeners, show/hide interactive elements, etc.
|
|
95
|
+
*
|
|
96
|
+
* Optional — tools without this method trigger a full save/clear/render
|
|
97
|
+
* fallback when read-only mode is toggled.
|
|
98
|
+
*/
|
|
99
|
+
setReadOnly?(state: boolean): void;
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
/**
|
package/bin/blok.mjs
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { run } from '../dist/cli.mjs';
|
|
5
|
-
|
|
6
|
-
const version = process.env.npm_package_version
|
|
7
|
-
|| JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version;
|
|
8
|
-
const args = process.argv.slice(2);
|
|
9
|
-
|
|
10
|
-
run(args, version);
|
package/dist/cli.mjs
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import * as e from "node:fs";
|
|
2
|
-
//#region src/cli/commands/migrationContent.ts
|
|
3
|
-
var t = "# Migrating from EditorJS to Blok\n\nThis guide covers the breaking changes when migrating from EditorJS to Blok.\n\n## Table of Contents\n\n- [Core Changes](#core-changes)\n- [Data Attributes](#data-attributes)\n- [CSS Classes](#css-classes)\n- [Bundled Tools](#bundled-tools)\n - [Tool Configuration](#tool-configuration)\n - [Lifecycle Hooks](#lifecycle-hooks)\n - [Delimiter → Divider](#delimiter--divider)\n- [Configuration Defaults](#configuration-defaults)\n- [New API Methods](#new-api-methods)\n- [DOM Selectors](#dom-selectors)\n- [E2E Test Selectors](#e2e-test-selectors)\n\n---\n\n## Core Changes\n\n### Class Name\n\n```diff\n- import EditorJS from '@editorjs/editorjs';\n+ import Blok from '@jackuait/blok';\n\n- const editor = new EditorJS({ ... });\n+ const editor = new Blok({ ... });\n```\n\n### Default Holder\n\nThe default holder ID changed from `editorjs` to `blok`:\n\n```diff\n- <div id=\"editorjs\"></div>\n+ <div id=\"blok\"></div>\n```\n\nOr specify explicitly:\n\n```javascript\nconst editor = new Blok({\n holder: 'my-editor', // works the same as before\n});\n```\n\n### TypeScript Types\n\n```diff\n- import type { EditorConfig, OutputData } from '@editorjs/editorjs';\n+ import type { BlokConfig, OutputData } from '@jackuait/blok';\n```\n\n---\n\n## Data Attributes\n\nBlok uses `data-blok-*` attributes instead of EditorJS's mixed naming conventions.\n\n| EditorJS | Blok |\n|----------|------|\n| `data-id` | `data-blok-id` |\n| `data-item-name` | `data-blok-item-name` |\n| `data-empty` | `data-blok-empty` |\n| `.ce-block--selected` (class) | `data-blok-selected=\"true\"` |\n| — | `data-blok-component` (tool name) |\n| — | `data-blok-interface` (element type) |\n| — | `data-blok-testid` (testing) |\n| — | `data-blok-opened` (toolbar state) |\n| — | `data-blok-placeholder` |\n| — | `data-blok-stretched` |\n| — | `data-blok-focused` |\n| — | `data-blok-popover-opened` |\n| — | `data-blok-tool` (tool name on rendered element) |\n| — | `data-blok-dragging` (block being dragged) |\n| — | `data-blok-hidden` (hidden state) |\n| — | `data-blok-rtl` (RTL mode) |\n| — | `data-blok-drag-handle` (drag handle element) |\n| — | `data-blok-overlay` (selection overlay) |\n| — | `data-blok-overlay-rectangle` (selection rectangle) |\n\n### Querying Blocks\n\n```diff\n- document.querySelector('[data-id=\"abc123\"]');\n+ document.querySelector('[data-blok-id=\"abc123\"]');\n\n- document.querySelector('[data-item-name=\"bold\"]');\n+ document.querySelector('[data-blok-item-name=\"bold\"]');\n```\n\n---\n\n## CSS Classes\n\nBlok replaces BEM class names with data attributes for selection.\n\n### Editor Wrapper\n\n| EditorJS | Blok |\n|----------|------|\n| `.codex-editor` | `[data-blok-editor]` |\n| `.codex-editor__redactor` | `[data-blok-redactor]` |\n| `.codex-editor--rtl` | `[data-blok-rtl=\"true\"]` |\n\n### Block Elements\n\n| EditorJS | Blok |\n|----------|------|\n| `.ce-block` | `[data-blok-element]` |\n| `.ce-block--selected` | `[data-blok-selected=\"true\"]` |\n| `.ce-block--stretched` | `[data-blok-stretched=\"true\"]` |\n| `.ce-block--focused` | `[data-blok-focused=\"true\"]` |\n| `.ce-block__content` | `[data-blok-element-content]` |\n\n### Toolbar\n\n| EditorJS | Blok |\n|----------|------|\n| `.ce-toolbar` | `[data-blok-toolbar]` |\n| `.ce-toolbar__plus` | `[data-blok-testid=\"plus-button\"]` |\n| `.ce-toolbar__settings-btn` | `[data-blok-settings-toggler]` |\n| `.ce-toolbar__actions` | `[data-blok-testid=\"toolbar-actions\"]` |\n| `.ce-toolbox` | `[data-blok-toolbox]` |\n| `.ce-toolbox--opened` | `[data-blok-toolbox][data-blok-opened=\"true\"]` |\n\n### Inline Toolbar\n\n| EditorJS | Blok |\n|----------|------|\n| `.ce-inline-toolbar` | `[data-blok-testid=\"inline-toolbar\"]` |\n| `.ce-inline-tool` | `[data-blok-testid=\"inline-tool\"]` |\n| `.ce-inline-tool--link` | `[data-blok-testid=\"inline-tool-link\"]` |\n| `.ce-inline-tool--bold` | `[data-blok-testid=\"inline-tool-bold\"]` |\n| `.ce-inline-tool--italic` | `[data-blok-testid=\"inline-tool-italic\"]` |\n\n### Popover\n\n| EditorJS | Blok |\n|----------|------|\n| `.ce-popover` | `[data-blok-popover]` |\n| `.ce-popover--opened` | `[data-blok-popover][data-blok-opened=\"true\"]` |\n| `.ce-popover__container` | `[data-blok-popover-container]` |\n| `.ce-popover-item` | `[data-blok-testid=\"popover-item\"]` |\n| `.ce-popover-item--focused` | `[data-blok-focused=\"true\"]` |\n| `.ce-popover-item--confirmation` | `[data-blok-confirmation=\"true\"]` |\n| `.ce-popover-item__icon` | `[data-blok-testid=\"popover-item-icon\"]` |\n| `.ce-popover-item__icon--tool` | `[data-blok-testid=\"popover-item-icon-tool\"]` |\n\n### Tool-Specific Classes\n\n| EditorJS | Blok |\n|----------|------|\n| `.ce-paragraph` | `[data-blok-tool=\"paragraph\"]` |\n| `.ce-header` | `[data-blok-tool=\"header\"]` |\n\n### Conversion Toolbar & Settings\n\n| EditorJS | Blok |\n|----------|------|\n| `.ce-conversion-toolbar` | `[data-blok-testid=\"conversion-toolbar\"]` |\n| `.ce-conversion-tool` | `[data-blok-testid=\"conversion-tool\"]` |\n| `.ce-settings` | `[data-blok-testid=\"block-settings\"]` |\n| `.ce-tune` | `[data-blok-testid=\"block-tune\"]` |\n\n### Other Elements\n\n| EditorJS | Blok |\n|----------|------|\n| `.ce-stub` | `[data-blok-stub]` |\n| `.ce-drag-handle` | `[data-blok-drag-handle]` |\n| `.ce-ragged-right` | `[data-blok-ragged-right=\"true\"]` |\n\n### CDX List Classes\n\n| EditorJS | Blok |\n|----------|------|\n| `.cdx-list` | `[data-blok-list]` |\n| `.cdx-list__item` | `[data-blok-list-item]` |\n| `.cdx-list--ordered` | `[data-blok-list=\"ordered\"]` |\n| `.cdx-list--unordered` | `[data-blok-list=\"unordered\"]` |\n\n### CDX Utility Classes\n\n| EditorJS | Blok |\n|----------|------|\n| `.cdx-button` | `[data-blok-button]` |\n| `.cdx-input` | `[data-blok-input]` |\n| `.cdx-loader` | `[data-blok-loader]` |\n| `.cdx-search-field` | `[data-blok-search-field]` |\n\n---\n\n## Bundled Tools\n\nBlok includes Header and Paragraph tools. No external packages needed:\n\n```diff\n- import Header from '@editorjs/header';\n- import Paragraph from '@editorjs/paragraph';\n\n+ import Blok from '@jackuait/blok';\n\nconst editor = new Blok({\n tools: {\n- header: Header,\n- paragraph: Paragraph,\n+ header: Blok.Header,\n+ paragraph: Blok.Paragraph, // optional, it's the default\n },\n});\n```\n\n### Tool Configuration\n\nBoth bundled tools accept configuration options:\n\n#### HeaderConfig\n\n```typescript\nimport type { HeaderConfig } from '@jackuait/blok';\n\nconst editor = new Blok({\n tools: {\n header: {\n class: Blok.Header,\n config: {\n placeholder: 'Enter a heading',\n levels: [2, 3, 4], // Restrict to H2-H4 only\n defaultLevel: 2, // Default to H2\n } as HeaderConfig,\n },\n },\n});\n```\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `placeholder` | `string` | `''` | Placeholder text for empty header |\n| `levels` | `number[]` | `[1,2,3,4,5,6]` | Available heading levels (1-6) |\n| `defaultLevel` | `number` | `2` | Default heading level |\n\n#### ParagraphConfig\n\n```typescript\nimport type { ParagraphConfig } from '@jackuait/blok';\n\nconst editor = new Blok({\n tools: {\n paragraph: {\n class: Blok.Paragraph,\n config: {\n placeholder: 'Start typing...',\n preserveBlank: true,\n } as ParagraphConfig,\n },\n },\n});\n```\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `placeholder` | `string` | `''` | Placeholder text for empty paragraph |\n| `preserveBlank` | `boolean` | `false` | Keep empty paragraphs when saving |\n\n### Delimiter → Divider\n\nEditor.js's `@editorjs/delimiter` is replaced by Blok's built-in `divider` tool:\n\n```diff\n- import Delimiter from '@editorjs/delimiter';\n\nconst editor = new Blok({\n tools: {\n- delimiter: Delimiter,\n+ divider: Blok.Divider,\n },\n});\n```\n\n**Saved data migration:** The block type name changed from `\"delimiter\"` to `\"divider\"`. The data format is identical (empty `{}`):\n\n```diff\n {\n- \"type\": \"delimiter\",\n+ \"type\": \"divider\",\n \"data\": {}\n }\n```\n\n> **Note:** Blok automatically recognizes `\"delimiter\"` blocks and renders them as dividers, so existing articles work without data migration. However, renaming the type in your database is recommended for consistency.\n\n---\n\n## Configuration Defaults\n\n| Option | EditorJS | Blok |\n|--------|----------|------|\n| `holder` | `\"editorjs\"` | `\"blok\"` |\n| `defaultBlock` | `\"paragraph\"` | `\"paragraph\"` |\n\n---\n\n## New API Methods\n\nBlok exposes shorthand methods directly on the instance:\n\n```javascript\nconst editor = new Blok({ ... });\n\n// Shorthand methods\nawait editor.save(); // Same as editor.saver.save()\neditor.clear(); // Same as editor.blocks.clear()\nawait editor.render(data);// Same as editor.blocks.render(data)\neditor.focus(); // Same as editor.caret.focus()\n\n// Event methods\neditor.on('change', handler); // Same as editor.events.on()\neditor.off('change', handler); // Same as editor.events.off()\neditor.emit('custom', data); // Same as editor.events.emit()\n```\n\n---\n\n## DOM Selectors\n\nBlok uses consistent `data-blok-interface` attributes for major UI components:\n\n```javascript\n// Find the editor wrapper\nconst editorWrapper = document.querySelector('[data-blok-interface=\"blok\"]');\n\n// Find inline toolbar\nconst inlineToolbar = document.querySelector('[data-blok-interface=\"inline-toolbar\"]');\n\n// Find tooltip\nconst tooltip = document.querySelector('[data-blok-interface=\"tooltip\"]');\n```\n\nThese selectors are stable and recommended for programmatic access to Blok's UI components.\n\n### Key Changes\n\n1. **Prefix custom classes** with `blok-` instead of `ce-` or `cdx-`\n2. **Use `data-blok-*`** attributes instead of `data-*`\n\n---\n\n## E2E Test Selectors\n\nUpdate your test selectors to use Blok's `data-blok-testid` attributes:\n\n| EditorJS | Blok |\n|----------|------|\n| `[data-cy=editorjs]` | `[data-blok-testid=\"blok-editor\"]` |\n| `.ce-block` | `[data-blok-element]` |\n| `.ce-block__content` | `[data-blok-element-content]` |\n| `.ce-toolbar` | `[data-blok-toolbar]` |\n| `.ce-toolbar__plus` | `[data-blok-testid=\"plus-button\"]` |\n| `.ce-toolbar__settings-btn` | `[data-blok-settings-toggler]` |\n| `.ce-toolbar__actions` | `[data-blok-testid=\"toolbar-actions\"]` |\n| `.ce-toolbox` | `[data-blok-toolbox]` |\n| `.ce-inline-toolbar` | `[data-blok-testid=\"inline-toolbar\"]` |\n| `.ce-popover` | `[data-blok-popover]` |\n| `.ce-popover-item` | `[data-blok-testid=\"popover-item\"]` |\n| `[data-item-name=\"...\"]` | `[data-blok-item-name=\"...\"]` |\n\n### Playwright Example\n\n```diff\n// EditorJS\n- await page.locator('[data-cy=editorjs] .ce-block').click();\n- await page.locator('.ce-toolbar__settings-btn').click();\n- await page.locator('[data-item-name=\"delete\"]').click();\n\n// Blok\n+ await page.locator('[data-blok-element]').click();\n+ await page.locator('[data-blok-settings-toggler]').click();\n+ await page.locator('[data-blok-item-name=\"delete\"]').click();\n```\n\n### Additional Test Selectors\n\n| Element | Selector |\n|---------|----------|\n| Block tunes popover | `[data-blok-testid=\"block-tunes-popover\"]` |\n| Popover search input | `[data-blok-testid=\"popover-search-input\"]` |\n| Popover item title | `[data-blok-testid=\"popover-item-title\"]` |\n| Popover overlay | `[data-blok-testid=\"popover-overlay\"]` |\n| Inline tool input | `[data-blok-testid=\"inline-tool-input\"]` |\n| Tooltip | `[data-blok-testid=\"tooltip\"]` |\n| Redactor | `[data-blok-testid=\"redactor\"]` |\n| Toolbox | `[data-blok-testid=\"toolbox\"]` |\n| Toolbox popover | `[data-blok-testid=\"toolbox-popover\"]` |\n| Selection overlay | `[data-blok-testid=\"overlay\"]` |\n| Selection rectangle | `[data-blok-testid=\"overlay-rectangle\"]` |\n\n---\n\n### State Selectors\n\n| State | Selector |\n|-------|----------|\n| Toolbar opened | `[data-blok-opened=\"true\"]` |\n| Block selected | `[data-blok-selected=\"true\"]` |\n| Popover opened | `[data-blok-popover-opened=\"true\"]` |\n| Item focused | `[data-blok-focused=\"true\"]` |\n| Item disabled | `[data-blok-disabled=\"true\"]` |\n| Nested popover | `[data-blok-nested=\"true\"]` |\n| Toolbox opened | `[data-blok-toolbox-opened=\"true\"]` |\n| Block dragging | `[data-blok-dragging=\"true\"]` |\n| Element hidden | `[data-blok-hidden=\"true\"]` |\n| RTL mode | `[data-blok-rtl=\"true\"]` |\n\n---\n\n## Quick Checklist\n\n- [ ] Replace `new EditorJS` with `new Blok`\n- [ ] Update imports from `@editorjs/*` to `@jackuait/blok`\n- [ ] Change holder from `editorjs` to `blok` (or specify explicitly)\n- [ ] Use bundled tools: `Blok.Header`, `Blok.Paragraph`\n- [ ] Replace `@editorjs/delimiter` with built-in `Blok.Divider`\n- [ ] Update `EditorConfig` type to `BlokConfig`\n- [ ] Replace `data-id` with `data-blok-id` in queries\n- [ ] Replace `data-item-name` with `data-blok-item-name`\n- [ ] Replace `.ce-*` selectors with `[data-blok-*]` attributes\n- [ ] Replace `.cdx-*` selectors with `[data-blok-*]` attributes\n- [ ] Update E2E test selectors\n", n = (e) => [
|
|
4
|
-
"# Blok Migration Guide (for LLM-assisted migration)",
|
|
5
|
-
"",
|
|
6
|
-
"> This document contains everything needed to migrate a project from EditorJS to Blok.",
|
|
7
|
-
"> Apply these changes systematically to the user's codebase.",
|
|
8
|
-
`> Current Blok version: ${e}`,
|
|
9
|
-
"",
|
|
10
|
-
"---",
|
|
11
|
-
""
|
|
12
|
-
].join("\n") + t, r = (t, n) => {
|
|
13
|
-
n ? (e.writeFileSync(n, t, "utf-8"), process.stderr.write(`Written to ${n}\n`)) : process.stdout.write(t);
|
|
14
|
-
}, i = "Usage: blok [options]\n\nOptions:\n --migration Output the EditorJS to Blok migration guide (LLM-friendly)\n --output <file> Write output to a file instead of stdout\n --help Show this help message\n\nExamples:\n npx @jackuait/blok --migration\n npx @jackuait/blok --migration | pbcopy\n npx @jackuait/blok --migration --output migration-guide.md\n", a = (e) => {
|
|
15
|
-
if (e.includes("--help")) return { command: "help" };
|
|
16
|
-
if (e.includes("--migration")) {
|
|
17
|
-
let t = e.indexOf("--output");
|
|
18
|
-
return {
|
|
19
|
-
command: "migration",
|
|
20
|
-
output: t === -1 ? void 0 : e[t + 1]
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
return { command: null };
|
|
24
|
-
}, o = (e, t) => {
|
|
25
|
-
let { command: o, output: s } = a(e);
|
|
26
|
-
switch (o) {
|
|
27
|
-
case "migration":
|
|
28
|
-
r(n(t), s);
|
|
29
|
-
break;
|
|
30
|
-
case "help":
|
|
31
|
-
case null:
|
|
32
|
-
process.stdout.write(i);
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
//#endregion
|
|
37
|
-
export { o as run };
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import type { LanguageEntry } from './constants';
|
|
2
|
-
import { SEARCH_LANGUAGE_KEY } from './constants';
|
|
3
|
-
|
|
4
|
-
interface I18n {
|
|
5
|
-
t: (key: string) => string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface LanguagePickerOptions {
|
|
9
|
-
languages: LanguageEntry[];
|
|
10
|
-
onSelect: (id: string) => void;
|
|
11
|
-
i18n: I18n;
|
|
12
|
-
activeLanguageId: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export class LanguagePicker {
|
|
16
|
-
private readonly _languages: LanguageEntry[];
|
|
17
|
-
private readonly _onSelect: (id: string) => void;
|
|
18
|
-
private readonly _i18n: I18n;
|
|
19
|
-
private _activeLanguageId: string;
|
|
20
|
-
|
|
21
|
-
private _element: HTMLElement;
|
|
22
|
-
private _searchInput: HTMLInputElement;
|
|
23
|
-
private _list: HTMLElement;
|
|
24
|
-
private _backdrop: HTMLElement | null = null;
|
|
25
|
-
private _anchorEl: HTMLElement | null = null;
|
|
26
|
-
|
|
27
|
-
constructor(options: LanguagePickerOptions) {
|
|
28
|
-
this._languages = options.languages;
|
|
29
|
-
this._onSelect = options.onSelect;
|
|
30
|
-
this._i18n = options.i18n;
|
|
31
|
-
this._activeLanguageId = options.activeLanguageId;
|
|
32
|
-
this._element = this.buildElement();
|
|
33
|
-
|
|
34
|
-
const searchInput = this._element.querySelector<HTMLInputElement>('[data-blok-testid="code-language-search"]');
|
|
35
|
-
const list = this._element.querySelector<HTMLElement>('[data-language-list]');
|
|
36
|
-
|
|
37
|
-
if (searchInput === null || list === null) {
|
|
38
|
-
throw new Error('LanguagePicker: failed to build required elements');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
this._searchInput = searchInput;
|
|
42
|
-
this._list = list;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
public getElement(): HTMLElement {
|
|
46
|
-
return this._element;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
public open(anchor: HTMLElement): void {
|
|
50
|
-
this._anchorEl = anchor;
|
|
51
|
-
this._searchInput.value = '';
|
|
52
|
-
this.renderList(this._languages);
|
|
53
|
-
|
|
54
|
-
this._element.hidden = false;
|
|
55
|
-
this.showBackdrop();
|
|
56
|
-
this.position(anchor);
|
|
57
|
-
this._searchInput.focus();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
public close(): void {
|
|
61
|
-
this._element.hidden = true;
|
|
62
|
-
this.removeBackdrop();
|
|
63
|
-
this._anchorEl?.focus();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
public setActiveLanguage(id: string): void {
|
|
67
|
-
this._activeLanguageId = id;
|
|
68
|
-
|
|
69
|
-
const buttons = Array.from(this._list.querySelectorAll<HTMLButtonElement>('button[data-language-id]'));
|
|
70
|
-
|
|
71
|
-
for (const btn of buttons) {
|
|
72
|
-
const isActive = btn.getAttribute('data-language-id') === id;
|
|
73
|
-
|
|
74
|
-
this.applyActiveStyle(btn, isActive);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ─── DOM Construction ─────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
private buildElement(): HTMLElement {
|
|
81
|
-
const el = document.createElement('div');
|
|
82
|
-
|
|
83
|
-
el.setAttribute('data-blok-testid', 'code-language-picker');
|
|
84
|
-
el.className = [
|
|
85
|
-
'fixed z-50 w-[240px] overflow-hidden rounded-lg',
|
|
86
|
-
'border border-neutral-200/70 bg-white shadow-xl',
|
|
87
|
-
'theme-dark:border-neutral-700/50 theme-dark:bg-neutral-900',
|
|
88
|
-
].join(' ');
|
|
89
|
-
el.hidden = true;
|
|
90
|
-
|
|
91
|
-
// Search wrapper
|
|
92
|
-
const searchWrapper = document.createElement('div');
|
|
93
|
-
|
|
94
|
-
searchWrapper.className = 'px-2 pt-2 pb-1';
|
|
95
|
-
|
|
96
|
-
const input = document.createElement('input');
|
|
97
|
-
|
|
98
|
-
input.type = 'text';
|
|
99
|
-
input.placeholder = this._i18n.t(SEARCH_LANGUAGE_KEY);
|
|
100
|
-
input.setAttribute('data-blok-testid', 'code-language-search');
|
|
101
|
-
input.className = [
|
|
102
|
-
'w-full text-xs rounded-md py-1.5 px-2.5 outline-hidden',
|
|
103
|
-
'bg-neutral-100 text-neutral-800 placeholder:text-neutral-400',
|
|
104
|
-
'theme-dark:bg-neutral-800 theme-dark:text-neutral-200 theme-dark:placeholder:text-neutral-500',
|
|
105
|
-
'focus:ring-2 focus:ring-neutral-300/60 theme-dark:focus:ring-neutral-600/60',
|
|
106
|
-
'transition-shadow duration-150',
|
|
107
|
-
].join(' ');
|
|
108
|
-
input.addEventListener('input', () => this.handleSearchChange(input.value));
|
|
109
|
-
|
|
110
|
-
searchWrapper.appendChild(input);
|
|
111
|
-
el.appendChild(searchWrapper);
|
|
112
|
-
|
|
113
|
-
// Language list
|
|
114
|
-
const list = document.createElement('div');
|
|
115
|
-
|
|
116
|
-
list.setAttribute('data-language-list', '');
|
|
117
|
-
list.className = 'max-h-[300px] overflow-y-auto px-1 pb-1';
|
|
118
|
-
el.appendChild(list);
|
|
119
|
-
|
|
120
|
-
// Keyboard handler
|
|
121
|
-
el.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
122
|
-
if (e.key === 'Escape') {
|
|
123
|
-
this.close();
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
return el;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ─── Rendering ────────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
private renderList(languages: LanguageEntry[]): void {
|
|
133
|
-
this._list.innerHTML = '';
|
|
134
|
-
|
|
135
|
-
for (const lang of languages) {
|
|
136
|
-
const btn = document.createElement('button');
|
|
137
|
-
|
|
138
|
-
btn.type = 'button';
|
|
139
|
-
btn.setAttribute('data-language-id', lang.id);
|
|
140
|
-
btn.textContent = lang.name;
|
|
141
|
-
btn.className = [
|
|
142
|
-
'w-full text-left text-xs px-2 py-1.5 rounded cursor-pointer',
|
|
143
|
-
'bg-transparent border-0',
|
|
144
|
-
'text-neutral-700 theme-dark:text-neutral-300',
|
|
145
|
-
'can-hover:hover:bg-neutral-100 theme-dark:can-hover:hover:bg-neutral-800',
|
|
146
|
-
'transition-colors',
|
|
147
|
-
].join(' ');
|
|
148
|
-
|
|
149
|
-
this.applyActiveStyle(btn, lang.id === this._activeLanguageId);
|
|
150
|
-
|
|
151
|
-
btn.addEventListener('click', () => {
|
|
152
|
-
this._onSelect(lang.id);
|
|
153
|
-
this.close();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
this._list.appendChild(btn);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
private applyActiveStyle(btn: HTMLButtonElement, active: boolean): void {
|
|
161
|
-
const activeClasses = ['bg-neutral-100', 'theme-dark:bg-neutral-800', 'font-medium'];
|
|
162
|
-
|
|
163
|
-
if (active) {
|
|
164
|
-
btn.classList.add(...activeClasses);
|
|
165
|
-
btn.setAttribute('data-active', 'true');
|
|
166
|
-
} else {
|
|
167
|
-
btn.classList.remove(...activeClasses);
|
|
168
|
-
btn.removeAttribute('data-active');
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private handleSearchChange(query: string): void {
|
|
173
|
-
const trimmed = query.trim().toLowerCase();
|
|
174
|
-
|
|
175
|
-
if (trimmed === '') {
|
|
176
|
-
this.renderList(this._languages);
|
|
177
|
-
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const filtered = this._languages.filter(
|
|
182
|
-
(lang) => lang.name.toLowerCase().includes(trimmed)
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
this.renderList(filtered);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ─── Backdrop ──────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
private showBackdrop(): void {
|
|
191
|
-
this.removeBackdrop();
|
|
192
|
-
|
|
193
|
-
const backdrop = document.createElement('div');
|
|
194
|
-
|
|
195
|
-
backdrop.setAttribute('data-blok-language-picker-backdrop', '');
|
|
196
|
-
backdrop.style.position = 'fixed';
|
|
197
|
-
backdrop.style.inset = '0';
|
|
198
|
-
backdrop.style.zIndex = '50';
|
|
199
|
-
|
|
200
|
-
backdrop.addEventListener('mousedown', (e: MouseEvent) => {
|
|
201
|
-
if (e.target === backdrop) {
|
|
202
|
-
this.close();
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
this._element.parentElement?.insertBefore(backdrop, this._element);
|
|
207
|
-
backdrop.appendChild(this._element);
|
|
208
|
-
this._backdrop = backdrop;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private removeBackdrop(): void {
|
|
212
|
-
if (this._backdrop === null) {
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
this._backdrop.parentElement?.insertBefore(this._element, this._backdrop);
|
|
217
|
-
this._backdrop.remove();
|
|
218
|
-
this._backdrop = null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ─── Positioning ──────────────────────────────────────────
|
|
222
|
-
|
|
223
|
-
private position(anchor: HTMLElement): void {
|
|
224
|
-
const rect = anchor.getBoundingClientRect();
|
|
225
|
-
const pickerRect = this._element.getBoundingClientRect();
|
|
226
|
-
const viewportHeight = window.innerHeight;
|
|
227
|
-
const viewportWidth = window.innerWidth;
|
|
228
|
-
|
|
229
|
-
const top = rect.bottom + pickerRect.height > viewportHeight
|
|
230
|
-
? rect.top - pickerRect.height - 4
|
|
231
|
-
: rect.bottom + 4;
|
|
232
|
-
|
|
233
|
-
const idealLeft = rect.left - 8;
|
|
234
|
-
const left = idealLeft + pickerRect.width > viewportWidth
|
|
235
|
-
? rect.right - pickerRect.width
|
|
236
|
-
: Math.max(0, idealLeft);
|
|
237
|
-
|
|
238
|
-
this._element.style.top = `${top}px`;
|
|
239
|
-
this._element.style.left = `${left}px`;
|
|
240
|
-
}
|
|
241
|
-
}
|