@phuong-tran-redoc/document-engine-core 0.1.0 → 0.1.2
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/README.md +105 -115
- package/THIRD-PARTY-NOTICES.txt +67 -0
- package/index.d.ts +1 -0
- package/index.esm.js +2238 -0
- package/package.json +21 -5
- package/src/__tests__/helpers/editor-factory.d.ts +0 -13
- package/src/__tests__/helpers/editor-factory.js +0 -25
- package/src/constants/indent.constant.js +0 -1
- package/src/constants/index.js +0 -1
- package/src/constants/table.constant.js +0 -1
- package/src/extensions/clear-content.extension.js +0 -9
- package/src/extensions/indent.extension.js +0 -59
- package/src/extensions/index.js +0 -8
- package/src/extensions/ordered-list.extension.js +0 -47
- package/src/extensions/reset-format.extension.js +0 -9
- package/src/extensions/reset-on-enter.extension.js +0 -37
- package/src/extensions/restricted-editing.extension.js +0 -376
- package/src/extensions/table-resizing.extension.js +0 -164
- package/src/extensions/table-style.extension.js +0 -481
- package/src/extensions/text-case.extension.js +0 -39
- package/src/index.js +0 -9
- package/src/kit/default-extensions.js +0 -86
- package/src/kit/generate-html.js +0 -40
- package/src/kit/index.js +0 -2
- package/src/migrations/doc-migrations.js +0 -24
- package/src/migrations/index.js +0 -3
- package/src/migrations/migrate-doc.js +0 -32
- package/src/migrations/types.js +0 -4
- package/src/models/color.model.js +0 -30
- package/src/models/index.js +0 -1
- package/src/nodes/dynamic-field.node.js +0 -141
- package/src/nodes/heading.node.js +0 -13
- package/src/nodes/image-ref.node.js +0 -138
- package/src/nodes/index.js +0 -4
- package/src/nodes/page-break.node.js +0 -52
- package/src/types/index.js +0 -2
- package/src/types/ordered-list.type.js +0 -2
- package/src/types/text-case.type.js +0 -1
- package/src/types/tiptap-html-server.d.js +0 -10
- package/src/utils/color.util.js +0 -31
- package/src/utils/dom.util.js +0 -33
- package/src/utils/index.js +0 -4
- package/src/utils/table.util.js +0 -95
- package/src/utils/text.util.js +0 -46
- package/src/views/block-handler.js +0 -311
- package/src/views/index.js +0 -1
package/src/index.js
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import Blockquote from '@tiptap/extension-blockquote';
|
|
2
|
-
import Bold from '@tiptap/extension-bold';
|
|
3
|
-
import Code from '@tiptap/extension-code';
|
|
4
|
-
import CodeBlock from '@tiptap/extension-code-block';
|
|
5
|
-
import { Document } from '@tiptap/extension-document';
|
|
6
|
-
import { HardBreak } from '@tiptap/extension-hard-break';
|
|
7
|
-
import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
|
|
8
|
-
import { Image } from '@tiptap/extension-image';
|
|
9
|
-
import Italic from '@tiptap/extension-italic';
|
|
10
|
-
import Link from '@tiptap/extension-link';
|
|
11
|
-
import { BulletList, ListItem } from '@tiptap/extension-list';
|
|
12
|
-
import { Paragraph } from '@tiptap/extension-paragraph';
|
|
13
|
-
import Strike from '@tiptap/extension-strike';
|
|
14
|
-
import { Subscript } from '@tiptap/extension-subscript';
|
|
15
|
-
import { Superscript } from '@tiptap/extension-superscript';
|
|
16
|
-
import { Text } from '@tiptap/extension-text';
|
|
17
|
-
import { TextAlign } from '@tiptap/extension-text-align';
|
|
18
|
-
import { TextStyleKit } from '@tiptap/extension-text-style';
|
|
19
|
-
import Underline from '@tiptap/extension-underline';
|
|
20
|
-
import { CustomOrderedList } from '../extensions/ordered-list.extension';
|
|
21
|
-
import { StyledTableKit } from '../extensions/table-style.extension';
|
|
22
|
-
import { DynamicField } from '../nodes/dynamic-field.node';
|
|
23
|
-
import { NotumHeading } from '../nodes/heading.node';
|
|
24
|
-
import { ImageRef } from '../nodes/image-ref.node';
|
|
25
|
-
import { PageBreak } from '../nodes/page-break.node';
|
|
26
|
-
/**
|
|
27
|
-
* Canonical default extension list for headless (Node-safe) serialization.
|
|
28
|
-
*
|
|
29
|
-
* This is the schema half of the editor's runtime kit (`DocumentEngineKit` in
|
|
30
|
-
* the Angular wrapper): every node, mark, and HTML-affecting attribute the
|
|
31
|
-
* editor can persist, so {@link generateHTML} can turn stored document JSON back
|
|
32
|
-
* into HTML without a browser.
|
|
33
|
-
*
|
|
34
|
-
* It deliberately omits editing-only plugins (history/undo-redo, drop/gap
|
|
35
|
-
* cursors, placeholder, character count, list keymaps, trailing node). Those
|
|
36
|
-
* shape the live editing experience but contribute nothing to serialized
|
|
37
|
-
* output, and some attach view-level behavior that has no meaning headless.
|
|
38
|
-
*
|
|
39
|
-
* Only `renderHTML` is exercised during serialization — node views (which touch
|
|
40
|
-
* the DOM, e.g. `DynamicField.addNodeView`) are never invoked here, so this list
|
|
41
|
-
* is safe to import and run in Node.
|
|
42
|
-
*
|
|
43
|
-
* Callers needing a different schema (e.g. restricted-editing regions, or a
|
|
44
|
-
* trimmed set) should pass their own array to {@link generateHTML} instead.
|
|
45
|
-
*/ export const defaultExtensions = [
|
|
46
|
-
// Structure
|
|
47
|
-
Document,
|
|
48
|
-
Paragraph,
|
|
49
|
-
Text,
|
|
50
|
-
NotumHeading,
|
|
51
|
-
HardBreak,
|
|
52
|
-
HorizontalRule,
|
|
53
|
-
Blockquote,
|
|
54
|
-
CodeBlock,
|
|
55
|
-
// Lists
|
|
56
|
-
BulletList,
|
|
57
|
-
ListItem,
|
|
58
|
-
CustomOrderedList,
|
|
59
|
-
// Marks
|
|
60
|
-
Bold,
|
|
61
|
-
Italic,
|
|
62
|
-
Underline,
|
|
63
|
-
Strike,
|
|
64
|
-
Code,
|
|
65
|
-
Link,
|
|
66
|
-
Subscript,
|
|
67
|
-
Superscript,
|
|
68
|
-
TextStyleKit,
|
|
69
|
-
// Block-level alignment (emits `text-align` only when explicitly set).
|
|
70
|
-
// NOTE: `Indent` is intentionally excluded — its `renderHTML` emits an empty
|
|
71
|
-
// `style=""` on every block even when unset, which pollutes otherwise-semantic
|
|
72
|
-
// output. Consumers that persist indentation can pass a custom extension array.
|
|
73
|
-
TextAlign.configure({
|
|
74
|
-
types: [
|
|
75
|
-
'paragraph',
|
|
76
|
-
'heading'
|
|
77
|
-
]
|
|
78
|
-
}),
|
|
79
|
-
// Content nodes
|
|
80
|
-
Image,
|
|
81
|
-
ImageRef,
|
|
82
|
-
PageBreak,
|
|
83
|
-
DynamicField,
|
|
84
|
-
// Tables (table / row / header / cell nodes)
|
|
85
|
-
StyledTableKit
|
|
86
|
-
];
|
package/src/kit/generate-html.js
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { defaultExtensions } from './default-extensions';
|
|
2
|
-
/**
|
|
3
|
-
* Serialize a Tiptap/ProseMirror document (JSON) to an HTML string.
|
|
4
|
-
*
|
|
5
|
-
* Environment-aware and Node-safe. Tiptap ships two serializers and the wrong
|
|
6
|
-
* one throws:
|
|
7
|
-
* - `@tiptap/html` needs a real `document` (browser / jsdom).
|
|
8
|
-
* - `@tiptap/html/server` is backed by happy-dom and runs headlessly in Node.
|
|
9
|
-
*
|
|
10
|
-
* The serializer is chosen at call time from whether a DOM is present, and
|
|
11
|
-
* imported lazily — so the heavy happy-dom build is never pulled into a browser
|
|
12
|
-
* editor bundle that only ever edits (and never serializes server-side), and
|
|
13
|
-
* Node backends never touch the browser-only build. This is why the function is
|
|
14
|
-
* async.
|
|
15
|
-
*
|
|
16
|
-
* @param doc The document as ProseMirror JSON (a `doc` node).
|
|
17
|
-
* @param extensions Schema to serialize against. Defaults to
|
|
18
|
-
* {@link defaultExtensions} (the editor's canonical kit). Pass
|
|
19
|
-
* a custom array when your documents use a different schema.
|
|
20
|
-
* @returns Semantic HTML. Presentation styling and sanitization (e.g. DOMPurify)
|
|
21
|
-
* are the consumer's responsibility — this helper does neither.
|
|
22
|
-
*/ export async function generateHTML(doc, extensions = defaultExtensions) {
|
|
23
|
-
if (typeof document !== 'undefined') {
|
|
24
|
-
// browser / jsdom: uses the ambient DOM
|
|
25
|
-
const serializer = await import('@tiptap/html');
|
|
26
|
-
return serializer.generateHTML(doc, extensions);
|
|
27
|
-
}
|
|
28
|
-
// Node: happy-dom backed, no real DOM needed. The specifier is built at runtime
|
|
29
|
-
// (not a static string literal) and flagged for webpack/vite so browser
|
|
30
|
-
// bundlers (esbuild/webpack/vite) can't statically resolve it and therefore
|
|
31
|
-
// never pull happy-dom — and its Node built-ins — into a client bundle. The
|
|
32
|
-
// `typeof document` guard means this branch never executes in a browser anyway.
|
|
33
|
-
const serverEntry = [
|
|
34
|
-
'@tiptap',
|
|
35
|
-
'html',
|
|
36
|
-
'server'
|
|
37
|
-
].join('/');
|
|
38
|
-
const serializer = await import(/* webpackIgnore: true */ /* @vite-ignore */ serverEntry);
|
|
39
|
-
return serializer.generateHTML(doc, extensions);
|
|
40
|
-
}
|
package/src/kit/index.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The current schema version. `EditorDocument`s authored by this build of the
|
|
3
|
-
* editor carry this value, and {@link migrateDoc} upgrades older documents up to
|
|
4
|
-
* it.
|
|
5
|
-
*
|
|
6
|
-
* Bump this by exactly one whenever a schema change requires transforming stored
|
|
7
|
-
* content, and add the matching entry (keyed by the previous version) to
|
|
8
|
-
* {@link docMigrations}.
|
|
9
|
-
*/ export const LATEST_SCHEMA_VERSION = 1;
|
|
10
|
-
/**
|
|
11
|
-
* Ordered migration steps, keyed by the source version they upgrade from.
|
|
12
|
-
*
|
|
13
|
-
* v0.1.0 ships an identity (empty) set: there is nothing to migrate yet, but the
|
|
14
|
-
* registry, walker ({@link migrateDoc}), and version field exist so future
|
|
15
|
-
* schema bumps are non-breaking. To add one, e.g. for the 1 -> 2 bump:
|
|
16
|
-
*
|
|
17
|
-
* ```ts
|
|
18
|
-
* export const docMigrations: DocMigrationRegistry = {
|
|
19
|
-
* 1: (doc) => ({ schemaVersion: 2, content: transformV1ToV2(doc.content) }),
|
|
20
|
-
* };
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* Each entry must be pure (no mutation, no browser APIs).
|
|
24
|
-
*/ export const docMigrations = {};
|
package/src/migrations/index.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { docMigrations, LATEST_SCHEMA_VERSION } from './doc-migrations';
|
|
2
|
-
/**
|
|
3
|
-
* Upgrade a stored document to {@link LATEST_SCHEMA_VERSION} by applying each
|
|
4
|
-
* registered migration in order.
|
|
5
|
-
*
|
|
6
|
-
* Pure and idempotent: a document already at the latest version is returned
|
|
7
|
-
* unchanged (a re-stamped copy), so batch runs are safe to re-run. A document
|
|
8
|
-
* with a missing/invalid `schemaVersion` is treated as version 1 (the earliest).
|
|
9
|
-
*
|
|
10
|
-
* @param doc The document to migrate.
|
|
11
|
-
* @param migrations Registry to apply. Defaults to {@link docMigrations}; the
|
|
12
|
-
* override exists for testing the walker in isolation.
|
|
13
|
-
* @param latest Target version. Defaults to {@link LATEST_SCHEMA_VERSION}.
|
|
14
|
-
* @returns A new `EditorDocument` at `latest`.
|
|
15
|
-
*/ export function migrateDoc(doc, migrations = docMigrations, latest = LATEST_SCHEMA_VERSION) {
|
|
16
|
-
let current = {
|
|
17
|
-
...doc,
|
|
18
|
-
schemaVersion: Number.isInteger(doc.schemaVersion) && doc.schemaVersion >= 1 ? doc.schemaVersion : 1
|
|
19
|
-
};
|
|
20
|
-
while(current.schemaVersion < latest){
|
|
21
|
-
const migrate = migrations[current.schemaVersion];
|
|
22
|
-
if (!migrate) {
|
|
23
|
-
throw new Error(`[migrateDoc] No migration registered for schema version ${current.schemaVersion} ` + `(target ${latest}).`);
|
|
24
|
-
}
|
|
25
|
-
const next = migrate(current);
|
|
26
|
-
if (next.schemaVersion <= current.schemaVersion) {
|
|
27
|
-
throw new Error(`[migrateDoc] Migration from version ${current.schemaVersion} did not advance the ` + `version (got ${next.schemaVersion}); migrations must be strictly increasing.`);
|
|
28
|
-
}
|
|
29
|
-
current = next;
|
|
30
|
-
}
|
|
31
|
-
return current;
|
|
32
|
-
}
|
package/src/migrations/types.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { normalizeColor } from '../utils';
|
|
2
|
-
export class Color {
|
|
3
|
-
static from(value, name, type) {
|
|
4
|
-
return new Color(value, name, type);
|
|
5
|
-
}
|
|
6
|
-
static fromArray(colors) {
|
|
7
|
-
return colors.map((color)=>{
|
|
8
|
-
if (typeof color === 'string') {
|
|
9
|
-
return new Color(color);
|
|
10
|
-
}
|
|
11
|
-
return new Color(color.value, color.name, color.type);
|
|
12
|
-
});
|
|
13
|
-
}
|
|
14
|
-
equals(color) {
|
|
15
|
-
return this.value === color.value;
|
|
16
|
-
}
|
|
17
|
-
is(color) {
|
|
18
|
-
return this.value === normalizeColor(color);
|
|
19
|
-
}
|
|
20
|
-
constructor(value, name, type){
|
|
21
|
-
const normalizedColor = normalizeColor(value);
|
|
22
|
-
if (!normalizedColor) {
|
|
23
|
-
throw new Error('Invalid or unsupported color.');
|
|
24
|
-
}
|
|
25
|
-
this.value = normalizedColor;
|
|
26
|
-
this.name = name ?? normalizedColor;
|
|
27
|
-
this.type = type;
|
|
28
|
-
Object.freeze(this);
|
|
29
|
-
}
|
|
30
|
-
}
|
package/src/models/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from './color.model';
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
-
/**
|
|
3
|
-
* Dynamic Field Node Extension
|
|
4
|
-
*
|
|
5
|
-
* This extension creates atomic, non-editable inline nodes that represent
|
|
6
|
-
* dynamic fields (e.g., {{customer_name}}) in document templates.
|
|
7
|
-
* These fields can be programmatically replaced with actual data later.
|
|
8
|
-
*/ export const DynamicField = Node.create({
|
|
9
|
-
name: 'dynamicField',
|
|
10
|
-
/**
|
|
11
|
-
* Node group configuration
|
|
12
|
-
* - 'inline': Makes the node behave as inline content
|
|
13
|
-
* - 'atom': true is critical for non-editable, atomic behavior
|
|
14
|
-
* - 'selectable': true allows users to select the entire node
|
|
15
|
-
* - 'draggable': false prevents users from dragging the node
|
|
16
|
-
*/ group: 'inline',
|
|
17
|
-
inline: true,
|
|
18
|
-
atom: true,
|
|
19
|
-
selectable: true,
|
|
20
|
-
draggable: false,
|
|
21
|
-
/**
|
|
22
|
-
* Define the attributes that this node can store
|
|
23
|
-
*/ addAttributes () {
|
|
24
|
-
return {
|
|
25
|
-
fieldId: {
|
|
26
|
-
default: null,
|
|
27
|
-
parseHTML: (element)=>element.getAttribute('data-field-id'),
|
|
28
|
-
renderHTML: (attributes)=>{
|
|
29
|
-
if (!attributes['fieldId']) return {};
|
|
30
|
-
return {
|
|
31
|
-
'data-field-id': attributes['fieldId']
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
label: {
|
|
36
|
-
default: null,
|
|
37
|
-
parseHTML: (element)=>element.getAttribute('data-label'),
|
|
38
|
-
renderHTML: (attributes)=>{
|
|
39
|
-
if (!attributes['label']) return {};
|
|
40
|
-
return {
|
|
41
|
-
'data-label': attributes['label']
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
},
|
|
47
|
-
/**
|
|
48
|
-
* Parse HTML to recognize dynamic field spans when loading/pasting content
|
|
49
|
-
*/ parseHTML () {
|
|
50
|
-
return [
|
|
51
|
-
// ----------------------------------------------------------
|
|
52
|
-
// RULE 1: Standard Tiptap Format (Ưu tiên cao nhất)
|
|
53
|
-
// ----------------------------------------------------------
|
|
54
|
-
{
|
|
55
|
-
tag: 'span[data-field-id]'
|
|
56
|
-
},
|
|
57
|
-
// ----------------------------------------------------------
|
|
58
|
-
// RULE 2: Legacy CKEditor Format (Migration Layer)
|
|
59
|
-
// ----------------------------------------------------------
|
|
60
|
-
{
|
|
61
|
-
// Selector CSS để nhận diện node của CKEditor
|
|
62
|
-
tag: 'span.red-dynamic-field',
|
|
63
|
-
// Hàm này chạy khi Tiptap tìm thấy tag trên
|
|
64
|
-
getAttrs: (node)=>{
|
|
65
|
-
if (typeof node === 'string') return false;
|
|
66
|
-
const element = node;
|
|
67
|
-
// 1. Trích xuất ID từ nội dung text: "{{ref}}" -> "ref"
|
|
68
|
-
const rawText = element.innerText || element.textContent || '';
|
|
69
|
-
// Regex để xóa dấu {{ và }} và khoảng trắng thừa
|
|
70
|
-
const id = rawText.replace(/{{|}}/g, '').trim();
|
|
71
|
-
// 2. Trích xuất Label từ attribute cũ
|
|
72
|
-
// CKEditor dùng 'value', 'dynamicfieldname' hoặc 'name'
|
|
73
|
-
const label = element.getAttribute('value') || element.getAttribute('dynamicfieldname') || element.getAttribute('name') || id; // Fallback nếu không tìm thấy label
|
|
74
|
-
// Trả về object khớp với cấu trúc 'addAttributes' ở trên
|
|
75
|
-
return {
|
|
76
|
-
fieldId: id,
|
|
77
|
-
label: label
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
];
|
|
82
|
-
},
|
|
83
|
-
/**
|
|
84
|
-
* Render the node as HTML
|
|
85
|
-
* Creates a span element with Tailwind classes for styling
|
|
86
|
-
* and stores attributes in data-* attributes for robustness
|
|
87
|
-
*/ renderHTML ({ HTMLAttributes, node }) {
|
|
88
|
-
const { fieldId, label } = node.attrs;
|
|
89
|
-
return [
|
|
90
|
-
'span',
|
|
91
|
-
mergeAttributes(HTMLAttributes, {
|
|
92
|
-
class: 'dynamic-field red-dynamic-field',
|
|
93
|
-
'data-field-id': fieldId,
|
|
94
|
-
'data-label': label
|
|
95
|
-
}),
|
|
96
|
-
// label || '',
|
|
97
|
-
fieldId ? `{{${fieldId}}}` : ''
|
|
98
|
-
];
|
|
99
|
-
},
|
|
100
|
-
/**
|
|
101
|
-
* NODE VIEW: Đây là phần giúp hiển thị giao diện giống CKEditor Widget
|
|
102
|
-
* Thay vì render ra text thuần, ta render ra một DOM element được style sẵn
|
|
103
|
-
*/ addNodeView () {
|
|
104
|
-
return ({ node })=>{
|
|
105
|
-
// 1. Tạo thẻ bao bọc (Wrapper)
|
|
106
|
-
const dom = document.createElement('span');
|
|
107
|
-
// 2. Thêm class để style (Dùng Tailwind như techstack của bạn )
|
|
108
|
-
// Bạn có thể thêm class 'ck-widget' nếu muốn dùng lại CSS cũ
|
|
109
|
-
dom.classList.add('dynamic-field', 'red-dynamic-field');
|
|
110
|
-
// 3. Gán dữ liệu label vào nội dung hiển thị
|
|
111
|
-
const { label, fieldId } = node.attrs;
|
|
112
|
-
dom.textContent = label || fieldId || 'Unknown Field';
|
|
113
|
-
return {
|
|
114
|
-
dom
|
|
115
|
-
};
|
|
116
|
-
};
|
|
117
|
-
},
|
|
118
|
-
/**
|
|
119
|
-
* Add custom commands for inserting dynamic fields
|
|
120
|
-
*/ addCommands () {
|
|
121
|
-
return {
|
|
122
|
-
insertDynamicField: (attributes)=>({ chain })=>{
|
|
123
|
-
if (!attributes.fieldId || !attributes.label) {
|
|
124
|
-
console.error('[DynamicField] Both fieldId and label are required.');
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
return chain().insertContent({
|
|
128
|
-
type: this.name,
|
|
129
|
-
attrs: attributes
|
|
130
|
-
}).run();
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
},
|
|
134
|
-
/**
|
|
135
|
-
* Default options
|
|
136
|
-
*/ addOptions () {
|
|
137
|
-
return {
|
|
138
|
-
HTMLAttributes: {}
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
});
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { Heading } from '@tiptap/extension-heading';
|
|
2
|
-
import '@tiptap/extension-paragraph';
|
|
3
|
-
export const NotumHeading = Heading.extend({
|
|
4
|
-
addCommands () {
|
|
5
|
-
return {
|
|
6
|
-
...this.parent?.(),
|
|
7
|
-
removeHeading: ()=>({ editor, chain })=>{
|
|
8
|
-
if (!editor.isActive('heading')) return false;
|
|
9
|
-
return chain().setParagraph().run();
|
|
10
|
-
}
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
});
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
-
/**
|
|
3
|
-
* `image-ref` — a semantic, URL-free reference to a media asset.
|
|
4
|
-
*
|
|
5
|
-
* Renders a `<figure data-block="image-ref" data-image-id="…">` (with an optional
|
|
6
|
-
* `<figcaption>`), decoupling document content from the resolved image URL. The
|
|
7
|
-
* consumer hydrates the figure by attribute (`data-image-id`) at render time —
|
|
8
|
-
* the document itself never embeds a URL or any presentation. In the editor the
|
|
9
|
-
* node shows a placeholder (it has no URL to display); resolving and showing the
|
|
10
|
-
* real image is the consumer's / media-picker's job.
|
|
11
|
-
*/ export const ImageRef = Node.create({
|
|
12
|
-
name: 'imageRef',
|
|
13
|
-
group: 'block',
|
|
14
|
-
atom: true,
|
|
15
|
-
selectable: true,
|
|
16
|
-
draggable: true,
|
|
17
|
-
addOptions () {
|
|
18
|
-
return {
|
|
19
|
-
HTMLAttributes: {}
|
|
20
|
-
};
|
|
21
|
-
},
|
|
22
|
-
addAttributes () {
|
|
23
|
-
return {
|
|
24
|
-
imageId: {
|
|
25
|
-
default: null,
|
|
26
|
-
parseHTML: (element)=>element.getAttribute('data-image-id'),
|
|
27
|
-
renderHTML: (attributes)=>{
|
|
28
|
-
if (!attributes['imageId']) return {};
|
|
29
|
-
return {
|
|
30
|
-
'data-image-id': attributes['imageId']
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
caption: {
|
|
35
|
-
default: null,
|
|
36
|
-
// Caption is rendered as <figcaption> text content (below), not a data
|
|
37
|
-
// attribute — so parse it back from the figcaption child for round-trip.
|
|
38
|
-
parseHTML: (element)=>{
|
|
39
|
-
const figcaption = element.querySelector(':scope > figcaption');
|
|
40
|
-
return figcaption?.textContent?.trim() || null;
|
|
41
|
-
},
|
|
42
|
-
renderHTML: ()=>({})
|
|
43
|
-
},
|
|
44
|
-
captionPosition: {
|
|
45
|
-
default: null,
|
|
46
|
-
// Normalize to the typed union: absent attribute stays `null` (preserves
|
|
47
|
-
// the byte-identical round-trip), any present-but-unknown value coerces to
|
|
48
|
-
// `'bottom'` so a non-`'top' | 'bottom'` string can never leak through.
|
|
49
|
-
parseHTML: (element)=>{
|
|
50
|
-
const raw = element.getAttribute('data-caption-position');
|
|
51
|
-
if (raw === null) return null;
|
|
52
|
-
return raw === 'top' ? 'top' : 'bottom';
|
|
53
|
-
},
|
|
54
|
-
renderHTML: (attributes)=>{
|
|
55
|
-
if (!attributes['captionPosition']) return {};
|
|
56
|
-
return {
|
|
57
|
-
'data-caption-position': attributes['captionPosition']
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
},
|
|
63
|
-
parseHTML () {
|
|
64
|
-
return [
|
|
65
|
-
{
|
|
66
|
-
tag: 'figure[data-block="image-ref"]'
|
|
67
|
-
}
|
|
68
|
-
];
|
|
69
|
-
},
|
|
70
|
-
renderHTML ({ HTMLAttributes, node }) {
|
|
71
|
-
const { caption } = node.attrs;
|
|
72
|
-
const figure = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
73
|
-
'data-block': 'image-ref'
|
|
74
|
-
});
|
|
75
|
-
// No <img> is emitted — the asset is URL-free. The caption order relative to
|
|
76
|
-
// the (not-yet-resolved) image is carried by `data-caption-position` for the
|
|
77
|
-
// consumer to honor at hydration time; the figure itself just holds the text.
|
|
78
|
-
if (!caption) {
|
|
79
|
-
return [
|
|
80
|
-
'figure',
|
|
81
|
-
figure
|
|
82
|
-
];
|
|
83
|
-
}
|
|
84
|
-
const figcaption = [
|
|
85
|
-
'figcaption',
|
|
86
|
-
{},
|
|
87
|
-
caption
|
|
88
|
-
];
|
|
89
|
-
return [
|
|
90
|
-
'figure',
|
|
91
|
-
figure,
|
|
92
|
-
figcaption
|
|
93
|
-
];
|
|
94
|
-
},
|
|
95
|
-
/**
|
|
96
|
-
* Editor-only placeholder. The DOM here is chrome (never serialized — that is
|
|
97
|
-
* `renderHTML`'s job); it just shows which media the block references.
|
|
98
|
-
*/ addNodeView () {
|
|
99
|
-
return ({ node })=>{
|
|
100
|
-
const { imageId, caption, captionPosition } = node.attrs;
|
|
101
|
-
const dom = document.createElement('figure');
|
|
102
|
-
dom.classList.add('image-ref-placeholder');
|
|
103
|
-
dom.setAttribute('data-block', 'image-ref');
|
|
104
|
-
if (imageId) dom.setAttribute('data-image-id', imageId);
|
|
105
|
-
if (captionPosition) dom.setAttribute('data-caption-position', captionPosition);
|
|
106
|
-
dom.style.cssText = 'margin:1rem 0;display:flex;flex-direction:column;gap:.5rem;';
|
|
107
|
-
const box = document.createElement('div');
|
|
108
|
-
box.style.cssText = 'display:flex;align-items:center;gap:.5rem;padding:1rem;border:1px dashed currentColor;' + 'border-radius:.5rem;opacity:.65;font-size:.875rem;justify-content:center;';
|
|
109
|
-
box.textContent = `🖼 image-ref · id: ${imageId || '—'}`;
|
|
110
|
-
const figcaption = document.createElement('figcaption');
|
|
111
|
-
figcaption.style.cssText = 'font-size:.8125rem;opacity:.7;text-align:center;';
|
|
112
|
-
if (caption) figcaption.textContent = caption;
|
|
113
|
-
if (caption && captionPosition === 'top') {
|
|
114
|
-
dom.append(figcaption, box);
|
|
115
|
-
} else {
|
|
116
|
-
dom.append(box);
|
|
117
|
-
if (caption) dom.append(figcaption);
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
dom
|
|
121
|
-
};
|
|
122
|
-
};
|
|
123
|
-
},
|
|
124
|
-
addCommands () {
|
|
125
|
-
return {
|
|
126
|
-
insertImageRef: (attributes)=>({ chain })=>{
|
|
127
|
-
if (!attributes.imageId) {
|
|
128
|
-
console.error('[ImageRef] imageId is required.');
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
return chain().insertContent({
|
|
132
|
-
type: this.name,
|
|
133
|
-
attrs: attributes
|
|
134
|
-
}).run();
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
});
|
package/src/nodes/index.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
-
export const PageBreak = Node.create({
|
|
3
|
-
name: 'pageBreak',
|
|
4
|
-
group: 'block',
|
|
5
|
-
atom: true,
|
|
6
|
-
selectable: true,
|
|
7
|
-
// addNodeView() {
|
|
8
|
-
// return createPageBreakNodeView;
|
|
9
|
-
// },
|
|
10
|
-
parseHTML () {
|
|
11
|
-
return [
|
|
12
|
-
// ----------------------------------------------------------
|
|
13
|
-
// RULE 1: Standard Tiptap Format (Ưu tiên cao nhất)
|
|
14
|
-
// ----------------------------------------------------------
|
|
15
|
-
{
|
|
16
|
-
tag: 'div[data-page-break]'
|
|
17
|
-
},
|
|
18
|
-
// ----------------------------------------------------------
|
|
19
|
-
// RULE 2: CKEditor Format (Ưu tiên thấp nhất)
|
|
20
|
-
// ----------------------------------------------------------
|
|
21
|
-
{
|
|
22
|
-
tag: 'div.page-break'
|
|
23
|
-
}
|
|
24
|
-
];
|
|
25
|
-
},
|
|
26
|
-
renderHTML ({ HTMLAttributes }) {
|
|
27
|
-
return [
|
|
28
|
-
'div',
|
|
29
|
-
mergeAttributes(HTMLAttributes, {
|
|
30
|
-
'data-page-break': 'true',
|
|
31
|
-
class: 'document-engine-page-break',
|
|
32
|
-
style: 'break-before: page; page-break-before: always;'
|
|
33
|
-
}),
|
|
34
|
-
[
|
|
35
|
-
'span',
|
|
36
|
-
{
|
|
37
|
-
class: 'document-engine-page-break-label'
|
|
38
|
-
},
|
|
39
|
-
'Page break'
|
|
40
|
-
]
|
|
41
|
-
];
|
|
42
|
-
},
|
|
43
|
-
addCommands () {
|
|
44
|
-
return {
|
|
45
|
-
insertPageBreak: ()=>({ chain })=>{
|
|
46
|
-
return chain().insertContent({
|
|
47
|
-
type: this.name
|
|
48
|
-
}).run();
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
});
|
package/src/types/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { };
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Type shim for the `@tiptap/html/server` subpath.
|
|
3
|
-
*
|
|
4
|
-
* `@tiptap/html` only exposes its Node-safe (`/server`) entry through the
|
|
5
|
-
* package `exports` map. This library compiles under `moduleResolution: node10`,
|
|
6
|
-
* which ignores `exports` for type resolution, so TypeScript cannot find the
|
|
7
|
-
* subpath's declarations on its own. The runtime (Jest/Node) resolves `/server`
|
|
8
|
-
* via the exports map fine — this declaration only satisfies the type checker by
|
|
9
|
-
* pointing it at the same public types as the bare package.
|
|
10
|
-
*/
|
package/src/utils/color.util.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Normalizes a color value to lowercase hex format
|
|
3
|
-
* Supports hex (#RGB, #RRGGBB) and rgb(r, g, b) formats
|
|
4
|
-
*
|
|
5
|
-
* @param color - Color value to normalize
|
|
6
|
-
* @returns Normalized hex color or null if invalid
|
|
7
|
-
*
|
|
8
|
-
* @example
|
|
9
|
-
* normalizeColor('#FF0000') // '#ff0000'
|
|
10
|
-
* normalizeColor('rgb(255, 0, 0)') // '#ff0000'
|
|
11
|
-
* normalizeColor('#F00') // '#f00'
|
|
12
|
-
* normalizeColor('invalid') // null
|
|
13
|
-
*/ export function normalizeColor(color) {
|
|
14
|
-
if (!color) return null;
|
|
15
|
-
const trimmed = color.trim().toLowerCase();
|
|
16
|
-
if (trimmed === 'transparent') return trimmed;
|
|
17
|
-
// Check if it's a valid hex color
|
|
18
|
-
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed)) {
|
|
19
|
-
return trimmed;
|
|
20
|
-
}
|
|
21
|
-
// Try to parse RGB
|
|
22
|
-
const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
|
|
23
|
-
if (rgbMatch) {
|
|
24
|
-
const r = Math.max(0, Math.min(255, parseInt(rgbMatch[1], 10)));
|
|
25
|
-
const g = Math.max(0, Math.min(255, parseInt(rgbMatch[2], 10)));
|
|
26
|
-
const b = Math.max(0, Math.min(255, parseInt(rgbMatch[3], 10)));
|
|
27
|
-
const toHex = (n)=>n.toString(16).padStart(2, '0');
|
|
28
|
-
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
29
|
-
}
|
|
30
|
-
return null;
|
|
31
|
-
}
|