@duplicati/ngclient 0.0.227 → 0.0.229
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/assets/sh.woff2 +0 -0
- package/{chunk-CDRHCSvU.js → chunk-0sb5_LpD.js} +1 -1
- package/chunk-2OH1SKyQ.js +1 -0
- package/chunk-A5DpxPOI.js +1 -0
- package/{chunk-BffC_BAn.js → chunk-B-fklW6A.js} +2 -2
- package/{chunk-D0t-kgLy.js → chunk-B12Syddi.js} +1 -1
- package/chunk-B2fqvpov.js +2 -0
- package/{chunk-ZVDEaGd0.js → chunk-B3yuN2OH.js} +1 -1
- package/{chunk-BAbAUjHY.js → chunk-B6vxBiK4.js} +1 -1
- package/{chunk-5zEiKCqR.js → chunk-B9zNFJSv.js} +1 -1
- package/{chunk-CdZVal1F.js → chunk-BAZ5npJw.js} +1 -1
- package/{chunk-Cz4OqhOZ.js → chunk-BDXLL1lt.js} +1 -1
- package/chunk-BFH9sFEg.js +1 -0
- package/{chunk-DddAUzMY.js → chunk-BIeNnIyg.js} +1 -1
- package/{chunk-ClHWI9-i.js → chunk-BJ6LKfIL.js} +1 -1
- package/{chunk-G-I5c1Dk.js → chunk-BYHopERA.js} +1 -1
- package/{chunk-BKLJMYh2.js → chunk-BZYVgHEz.js} +1 -1
- package/chunk-BZqhgAZ-.js +2 -0
- package/{chunk-DNJQ-AbV.js → chunk-Bc4gFy5W.js} +1 -1
- package/{chunk-CeFh90sX.js → chunk-BcpsSgKo.js} +1 -1
- package/{chunk-BNf0YAwH.js → chunk-Bf1diO1u.js} +1 -1
- package/{chunk-N7vpDAsp.js → chunk-BjkwnDTL.js} +1 -1
- package/{chunk-B6nCQM-G.js → chunk-BoCnrrB4.js} +1 -1
- package/{chunk-BxxX98wy.js → chunk-BtzP4vdU.js} +1 -1
- package/{chunk-CnAb3OKU.js → chunk-Byb051F6.js} +1 -1
- package/{chunk-C08PrcBX.js → chunk-C11i_gv7.js} +1 -1
- package/{chunk-DuO9_dH0.js → chunk-C1t4wbdG.js} +1 -1
- package/{chunk-Bzp0ULy_.js → chunk-C2bnZ62D.js} +1 -1
- package/{chunk-Do6_xsGi.js → chunk-C9EdrQe6.js} +3 -3
- package/chunk-CA-ER0DL.js +2 -0
- package/{chunk-CxPG-KgI.js → chunk-CBdr-6_u.js} +2 -2
- package/{chunk-ClfSJEUJ.js → chunk-CByrJpDb.js} +1 -1
- package/chunk-CI78bAye.js +2 -0
- package/chunk-CMmgPgiU.js +2 -0
- package/{chunk-CMs999gl.js → chunk-COgNe44h.js} +1 -1
- package/{chunk-CtLFpDTP.js → chunk-CQ8rm_d9.js} +1 -1
- package/{chunk-B7PRtY7v.js → chunk-CRU3lYvk.js} +1 -1
- package/{chunk-BUF3tczf.js → chunk-CUxq_s_t.js} +8 -8
- package/chunk-CXEImxhE.js +1 -0
- package/chunk-C_JveZJH.js +1 -0
- package/{chunk-DtQIjLqL.js → chunk-CdKfNiwm.js} +1 -1
- package/{chunk-PA5BGFyW.js → chunk-CfoJuTJX.js} +1 -1
- package/chunk-CgYrHQGO.js +1 -0
- package/{chunk-wycXedbJ.js → chunk-Cs0rlpWi.js} +1 -1
- package/{chunk-DNSvQk_3.js → chunk-CwB_t7g-.js} +1 -1
- package/chunk-D0u1cyeL.js +1 -0
- package/chunk-D1wHRltX.js +2 -0
- package/{chunk-M5tldLvJ.js → chunk-D28lTX0k.js} +1 -1
- package/{chunk--rqB0ojD.js → chunk-D2XTQURr.js} +1 -1
- package/{chunk-bx0aDvCZ.js → chunk-D48AStVy.js} +1 -1
- package/chunk-D4UZC_u0.js +1 -0
- package/{chunk-DnD9XTM4.js → chunk-DC8QqmzV.js} +1 -1
- package/{chunk-ChwFViRh.js → chunk-DDVdXEVe.js} +1 -1
- package/{chunk-r4LfAQnM.js → chunk-DDo7wKQT.js} +1 -1
- package/{chunk-DWwNINBJ.js → chunk-DJbAxHpK.js} +1 -1
- package/{chunk-C-G3pKB4.js → chunk-DMJycFPC.js} +1 -1
- package/chunk-DP9SoPGV.js +1 -0
- package/{chunk-BjNuU0J_.js → chunk-DQW8lSiz.js} +1 -1
- package/{chunk-Dt8gZPT-.js → chunk-DQwpGI_E.js} +1 -1
- package/chunk-DV2SIgNN.js +2 -0
- package/{chunk-g_QJ3SVt.js → chunk-DVgkAgur.js} +1 -1
- package/{chunk-BeAI10wv.js → chunk-DVlpMN2Q.js} +1 -1
- package/{chunk-BYoKu9PJ.js → chunk-D_A6A07f.js} +1 -1
- package/chunk-D_JCqKo8.js +1 -0
- package/{chunk-IEVZ0UN6.js → chunk-DaIpTiYw.js} +1 -1
- package/{chunk-CJRbWZfg.js → chunk-DiZeSQXj.js} +1 -1
- package/{chunk-BOII3Fdm.js → chunk-Drc5kcUX.js} +1 -1
- package/{chunk-BthLY9tf.js → chunk-GVxWY6X-.js} +1 -1
- package/{chunk-DofVJ9gn.js → chunk-JA7rUsYF.js} +1 -1
- package/{chunk-SWsliUUy.js → chunk-M21GJPzH.js} +3 -3
- package/{chunk-CUxvfCTH.js → chunk-MvQwR7nW.js} +1 -1
- package/{chunk-Cc1_FUvV.js → chunk-ObToq7vh.js} +1 -1
- package/chunk-RKTSBnYB.js +1 -0
- package/{chunk-BaWD8G-f.js → chunk-SKxVK_pD.js} +1 -1
- package/{chunk-C4pJCbq-.js → chunk-SycscfJp.js} +1 -1
- package/{chunk-Cw8vFX0e.js → chunk-bgWe6t7y.js} +1 -1
- package/{chunk-XMwSOila.js → chunk-cLFxUFjv.js} +1 -1
- package/{chunk-ySdaWJYB.js → chunk-dScOwyAU.js} +1 -1
- package/{chunk-BqjWEFHw.js → chunk-w_Hs_Uxi.js} +1 -1
- package/chunk-wyer8fjS.js +1 -0
- package/index.html +1 -1
- package/locale/messages.en.json +23 -3
- package/locale/messages.json +23 -3
- package/{main-TZVLLK67.js → main-PXGZLKMN.js} +14 -14
- package/package.json +1 -1
- package/ship-ui-assets/mcp/components.json +258 -86
- package/chunk-B-ryE8KW.js +0 -2
- package/chunk-B6ugJDDx.js +0 -1
- package/chunk-B88MJ6Up.js +0 -1
- package/chunk-BCT9ajP9.js +0 -1
- package/chunk-BMGXb3gX.js +0 -1
- package/chunk-BPpheyQK.js +0 -2
- package/chunk-BS8yAz3P.js +0 -2
- package/chunk-BlIcwFlf.js +0 -1
- package/chunk-BuyCr2eD.js +0 -1
- package/chunk-CM11IMzh.js +0 -1
- package/chunk-CU-RYihf.js +0 -2
- package/chunk-CuU6b3lp.js +0 -2
- package/chunk-D-epCTmE.js +0 -1
- package/chunk-QlYDF-EM.js +0 -1
- package/chunk-SgcWXTYZ.js +0 -2
- package/chunk-birClroL.js +0 -2
- package/chunk-ya_5JcKA.js +0 -1
|
@@ -18,6 +18,163 @@
|
|
|
18
18
|
"cssVariables": [],
|
|
19
19
|
"examples": []
|
|
20
20
|
},
|
|
21
|
+
{
|
|
22
|
+
"name": "ShipEditorRegistry",
|
|
23
|
+
"selector": "ship-editor-registry",
|
|
24
|
+
"path": "core/projects/ship-ui/ship-editor/ship-editor-core.ts",
|
|
25
|
+
"description": "### Usage\n\nShipEditor is a premium, config-driven WYSIWYG rich text editor. It allows typing in design view, editing raw source code, and can serialize to `html`, `markdown`, or `json` configuration dynamically.\n\n### Features\n\n- Three output formats: HTML strings, Markdown text, and custom structured block/mark JSON.\n- ControlValueAccessor integration for Reactive and Template-driven forms.\n- Monospace code-view for editing source code directly.\n- Dynamic toolbar selection tracking.\n- Word and character count reporting in the status footer.",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"editor",
|
|
28
|
+
"wysiwyg",
|
|
29
|
+
"richtext",
|
|
30
|
+
"html",
|
|
31
|
+
"markdown",
|
|
32
|
+
"json",
|
|
33
|
+
"input",
|
|
34
|
+
"form"
|
|
35
|
+
],
|
|
36
|
+
"inputs": [],
|
|
37
|
+
"outputs": [],
|
|
38
|
+
"methods": [
|
|
39
|
+
{
|
|
40
|
+
"name": "runTransaction",
|
|
41
|
+
"parameters": "action: (\n doc: ShipEditorDocument,\n selection: { start: LogicalPosition; end: LogicalPosition }\n ) =>\n | {\n doc?: ShipEditorDocument;\n selectionShift?: {\n start: { blockIndex: number; listItemIndex?: number };\n end: { blockIndex: number; listItemIndex?: number };\n };\n }\n | ShipEditorDocument\n | void\n | null",
|
|
42
|
+
"returnType": "void;\n applyInlineStyle(tag: string): void;\n toggleLink(url: string): void;\n setBlockType(tag: string): void;\n toggleList(listType: 'ul' | 'ol'): void;\n insertHorizontalRule(): void;\n setAlign(direction: 'left' | 'center' | 'right'): void;\n removeFormat(): void;\n insertImage(url: string): void;\n undo(): void;\n redo(): void;\n selectBlockType(tag: string): void;\n formatText(command: string, value?: string): void;\n openImageModal(): void;\n openLinkModal(): void;\n}\n\nexport interface ShipEditorCommand",
|
|
43
|
+
"description": ""
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "registerMark",
|
|
47
|
+
"parameters": "ext: ShipEditorMarkExtension",
|
|
48
|
+
"returnType": "any",
|
|
49
|
+
"description": ""
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "getAllMarks",
|
|
53
|
+
"parameters": "",
|
|
54
|
+
"returnType": "ShipEditorMarkExtension[]",
|
|
55
|
+
"description": ""
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "registerBlock",
|
|
59
|
+
"parameters": "ext: ShipEditorBlockExtension",
|
|
60
|
+
"returnType": "any",
|
|
61
|
+
"description": ""
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "getAllBlocks",
|
|
65
|
+
"parameters": "",
|
|
66
|
+
"returnType": "ShipEditorBlockExtension[]",
|
|
67
|
+
"description": ""
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "clear",
|
|
71
|
+
"parameters": "",
|
|
72
|
+
"returnType": "any",
|
|
73
|
+
"description": ""
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "registerDefaults",
|
|
77
|
+
"parameters": "",
|
|
78
|
+
"returnType": "any",
|
|
79
|
+
"description": ""
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "registerDefaultMarks",
|
|
83
|
+
"parameters": "",
|
|
84
|
+
"returnType": "any",
|
|
85
|
+
"description": ""
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "clearBlocks",
|
|
89
|
+
"parameters": "",
|
|
90
|
+
"returnType": "any",
|
|
91
|
+
"description": ""
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "sanitizeNode",
|
|
95
|
+
"parameters": "child);\n }\n\n return body.innerHTML;\n}\n\n// HTML => JSON Converter\nexport function htmlToJSON(\n html: string,\n docObj: Document = globalThis.document,\n registry: ShipEditorRegistry = ShipEditorRegistry.getInstance()",
|
|
96
|
+
"returnType": "ShipEditorDocument",
|
|
97
|
+
"description": ""
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "traverse",
|
|
101
|
+
"parameters": "child);\n }\n\n return md;\n}\n\n// Markdown => HTML Converter\nexport function markdownToHTML(\n markdown: string,\n registry: ShipEditorRegistry = ShipEditorRegistry.getInstance()",
|
|
102
|
+
"returnType": "string",
|
|
103
|
+
"description": ""
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"name": "while",
|
|
107
|
+
"parameters": "(n = walker.nextNode())",
|
|
108
|
+
"returnType": "any",
|
|
109
|
+
"description": ""
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
"cssVariables": [
|
|
113
|
+
{
|
|
114
|
+
"name": "--editor-bg",
|
|
115
|
+
"defaultValue": "var(--base-1)"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "--editor-code-bg",
|
|
119
|
+
"defaultValue": "var(--base-2)"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "--editor-border-color",
|
|
123
|
+
"defaultValue": "var(--base-4)"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "--editor-toolbar-bg",
|
|
127
|
+
"defaultValue": "rgb(from var(--base-1) r g b / 70%)"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "--editor-toolbar-border",
|
|
131
|
+
"defaultValue": "var(--base-4)"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"name": "--editor-btn-hover",
|
|
135
|
+
"defaultValue": "var(--base-3)"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "--editor-btn-active-bg",
|
|
139
|
+
"defaultValue": "var(--primary-3)"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "--editor-btn-active-c",
|
|
143
|
+
"defaultValue": "var(--primary-11)"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"name": "--editor-footer-bg",
|
|
147
|
+
"defaultValue": "var(--base-2)"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"name": "--editor-p",
|
|
151
|
+
"defaultValue": "#{p2r(16)} #{p2r(20)}"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"name": "--editor-min-h",
|
|
155
|
+
"defaultValue": "#{p2r(220)}"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"name": "--editor-border-focus",
|
|
159
|
+
"defaultValue": "var(--primary-8)"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"name": "--editor-shadow-focus",
|
|
163
|
+
"defaultValue": "0 0 0 3px var(--primary-3)"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "--editor-shape",
|
|
167
|
+
"defaultValue": "var(--shape-3)"
|
|
168
|
+
}
|
|
169
|
+
],
|
|
170
|
+
"examples": [
|
|
171
|
+
{
|
|
172
|
+
"name": "sandbox-editor",
|
|
173
|
+
"html": "<div class=\"sandbox-container\">\n <div class=\"sandbox-sidebar\">\n <sh-card class=\"type-b control-card\">\n <h3>Options</h3>\n\n <div class=\"control-group\">\n <label class=\"control-label\">Storage Format</label>\n <sh-button-group size=\"small\" [value]=\"selectedFormat()\" (valueChange)=\"onFormatChange($event)\">\n <button value=\"html\">HTML</button>\n <button value=\"markdown\">Markdown</button>\n <button value=\"json\">JSON</button>\n </sh-button-group>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"readonly-toggle\" class=\"control-label\">Read-Only</label>\n <sh-toggle id=\"readonly-toggle\" [(checked)]=\"isReadonly\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"toolbar-toggle\" class=\"control-label\">Show Toolbar</label>\n <sh-toggle id=\"toolbar-toggle\" [(checked)]=\"showToolbar\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"image-upload-toggle\" class=\"control-label\">Image Upload</label>\n <sh-toggle id=\"image-upload-toggle\" [(checked)]=\"imageUploadEnabled\"></sh-toggle>\n </div>\n\n @if (showToolbar()) {\n <div class=\"control-group-section-title\">Toolbar Groups</div>\n\n <div class=\"control-group inline\">\n <label for=\"formats-toggle\" class=\"control-label\">Formats</label>\n <sh-toggle id=\"formats-toggle\" [(checked)]=\"showFormats\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"blocks-toggle\" class=\"control-label\">Blocks/Headings</label>\n <sh-toggle id=\"blocks-toggle\" [(checked)]=\"showBlocks\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"lists-toggle\" class=\"control-label\">Lists</label>\n <sh-toggle id=\"lists-toggle\" [(checked)]=\"showLists\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"align-toggle\" class=\"control-label\">Alignments</label>\n <sh-toggle id=\"align-toggle\" [(checked)]=\"showAlignments\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"insert-toggle\" class=\"control-label\">Insertions</label>\n <sh-toggle id=\"insert-toggle\" [(checked)]=\"showInsertions\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"history-toggle\" class=\"control-label\">History/Code</label>\n <sh-toggle id=\"history-toggle\" [(checked)]=\"showHistory\"></sh-toggle>\n </div>\n }\n </sh-card>\n </div>\n\n <div class=\"sandbox-editor-wrapper\">\n <sh-editor\n [(value)]=\"editorValue\"\n [format]=\"selectedFormat()\"\n [readonly]=\"isReadonly()\"\n [toolbar]=\"showToolbar()\"\n [showFormats]=\"showFormats()\"\n [showBlocks]=\"showBlocks()\"\n [showLists]=\"showLists()\"\n [showAlignments]=\"showAlignments()\"\n [showInsertions]=\"showInsertions()\"\n [showHistory]=\"showHistory()\"\n [customCommands]=\"customEditorCommands()\"\n [imageUploadEnabled]=\"imageUploadEnabled()\"\n placeholder=\"Start writing your premium content here...\">\n <!-- Projected custom toolbar action -->\n <!-- <button type=\"button\" class=\"sh-editor-btn\" (click)=\"customAction()\" shTooltip=\"Projected Action (Star)\">\n <sh-icon>star</sh-icon>\n </button> -->\n </sh-editor>\n\n <sh-card class=\"type-b output-card\">\n <div class=\"output-header\">\n <h3>Database Output</h3>\n <button shButton variant=\"outlined\" size=\"small\" (click)=\"copyToClipboard()\">\n <sh-icon>copy</sh-icon>\n Copy\n </button>\n </div>\n <p class=\"output-desc\">This is exactly what would be saved in your database:</p>\n <pre class=\"serialized-preview\"><code>{{ serializedOutput() }}</code></pre>\n </sh-card>\n </div>\n</div>\n",
|
|
174
|
+
"ts": "import { ChangeDetectionStrategy, Component, computed, signal, viewChild } from '@angular/core';\nimport { ShipColor } from '@ship-ui/core';\nimport { ShipButton } from '@ship-ui/core/ship-button';\nimport { ShipButtonGroup } from '@ship-ui/core/ship-button-group';\nimport { ShipCard } from '@ship-ui/core/ship-card';\nimport { ShipEditor, ShipEditorCommand, ShipEditorRegistry, ShipEditorValue } from '@ship-ui/core/ship-editor';\nimport { ShipIcon } from '@ship-ui/core/ship-icon';\nimport { ShipToggle } from '@ship-ui/core/ship-toggle';\n\n@Component({\n selector: 'app-sandbox-editor',\n standalone: true,\n imports: [ShipEditor, ShipButton, ShipCard, ShipToggle, ShipIcon, ShipButtonGroup],\n templateUrl: './sandbox-editor.html',\n styleUrl: './sandbox-editor.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SandboxEditor {\n constructor() {\n // Register custom highlight mark extension\n ShipEditorRegistry.getInstance().registerMark({\n type: 'highlight',\n tagName: 'mark',\n toHTML: (mark: any, text: string) =>\n `<mark style=\"background-color: var(--warn-2); color: var(--warn-11); padding: 2px 4px; border-radius: 2px;\">${text}</mark>`,\n parseHTML: (el: HTMLElement) => (el.tagName.toLowerCase() === 'mark' ? { type: 'highlight' } : null),\n });\n }\n\n // Current format selection\n selectedFormat = signal<'html' | 'markdown' | 'json'>('html');\n\n editor = viewChild(ShipEditor);\n\n // Editor states\n isReadonly = signal<boolean>(false);\n showToolbar = signal<boolean>(true);\n selectedColor = signal<ShipColor | null>(null);\n imageUploadEnabled = signal<boolean>(true);\n\n // Toolbar group configurations\n showFormats = signal<boolean>(true);\n showBlocks = signal<boolean>(true);\n showLists = signal<boolean>(true);\n showAlignments = signal<boolean>(true);\n showInsertions = signal<boolean>(true);\n showHistory = signal<boolean>(true);\n\n // Custom action for projected button\n customAction() {\n alert('Custom Projected Button Clicked!');\n }\n\n customEditorCommands = signal<ShipEditorCommand[]>([\n // {\n // id: 'highlight',\n // label: 'Highlight Text',\n // icon: 'star',\n // description: 'Highlight selected text in yellow',\n // action: (editor) => {\n // editor.runTransaction((doc, selection) => {\n // return formatDocRange(doc, selection.start, selection.end, 'highlight', 'toggle');\n // });\n // },\n // },\n // {\n // id: 'info-callout',\n // label: 'Info Callout',\n // icon: 'terminal',\n // description: 'Insert an info callout box',\n // action: (editor) => {\n // editor.runTransaction((doc, selection) => {\n // const { doc: splitDoc } = splitBlock(doc, selection.start);\n // const calloutBlock: ShipEditorBlock = {\n // type: 'quote',\n // attrs: { align: 'left' },\n // content: [{ type: 'text', text: '💡 Info: Start typing callout contents here...' }],\n // };\n // const targetIndex = selection.start.blockIndex + 1;\n // splitDoc.splice(targetIndex, 0, calloutBlock);\n // return {\n // doc: splitDoc,\n // selectionShift: {\n // start: { blockIndex: targetIndex },\n // end: { blockIndex: targetIndex },\n // },\n // };\n // });\n // },\n // },\n ]);\n\n // Pre-populated initial contents for each format\n initialHtml = `<h1>Ship WYSIWYG Editor</h1><p>Welcome! This is a <strong>config-driven</strong> rich-text editor designed to support flexible storage formats.</p><ul><li><strong>Two-way binding</strong> with <code>ControlValueAccessor</code></li><li>Instant conversion to <strong>HTML</strong>, <strong>Markdown</strong>, or <strong>JSON</strong></li><li>Sticky blur-toolbar, light/dark mode support, and word counting</li></ul><blockquote>\"A beautiful interface makes editing content a delight.\"</blockquote><hr><p>Try changing the storage format below to see the serialized output update in real time!</p>`;\n\n // Current value model\n editorValue = signal<ShipEditorValue>(this.initialHtml);\n\n // Stringified preview for database presentation\n serializedOutput = computed(() => {\n const val = this.editorValue();\n if (val === null || val === undefined) return '';\n if (typeof val === 'string') return val;\n return JSON.stringify(val, null, 2);\n });\n\n // Handle format swapping, converting the value appropriately\n onFormatChange(format: string | null) {\n if (format === 'html' || format === 'markdown' || format === 'json') {\n this.selectedFormat.set(format);\n }\n }\n\n // Copy serialized contents to clipboard\n copyToClipboard() {\n navigator.clipboard.writeText(this.serializedOutput());\n }\n}\n"
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
},
|
|
21
178
|
{
|
|
22
179
|
"name": "ShipEditor",
|
|
23
180
|
"selector": "sh-editor",
|
|
@@ -49,7 +206,7 @@
|
|
|
49
206
|
{
|
|
50
207
|
"name": "readonly",
|
|
51
208
|
"type": "boolean",
|
|
52
|
-
"description": "",
|
|
209
|
+
"description": "Block extensions to use. Defaults to the built-in set. Override to customize behavior or add custom blocks. @example ```typescript import { defaultBlockExtensions, configureExtension, imageBlockExtension } from 'ship-ui/ship-editor'; myExtensions = [ ...defaultBlockExtensions.filter(e => e.type !== 'image'), configureExtension(imageBlockExtension, { defaultMode: 'content', defaultSize: 'auto' }), ]; ``` / extensions = input<ShipEditorBlockExtension[]>(defaultBlockExtensions); #selectedImage = signal<HTMLImageElement | null>(null); imgMode = signal<'content' | 'theater' | 'float' | 'custom'>('content'); imgSize = signal<'auto' | 'small' | 'medium' | 'large'>('auto'); /** Tracks the DOM element with an extension-driven activeClassName applied. */ #activeBlockEl: HTMLElement | null = null; viewMode = signal<'design' | 'code'>('design'); isFocused = signal<boolean>(false); showLinkModal = signal<boolean>(false); showImageModal = signal<boolean>(false); rawCodeValue = signal<string>(''); showBlockMenu = signal<boolean>(false); isBold = signal<boolean>(false); isItalic = signal<boolean>(false); isUnderline = signal<boolean>(false); isStrike = signal<boolean>(false); align = signal<'left' | 'center' | 'right'>('left'); activeBlock = signal<string>('p'); canUndo = signal<boolean>(false); canRedo = signal<boolean>(false); #savedRange: Range | null = null; documentState = signal<ShipEditorDocument>([]); getBlockInlineHTML(block: any): string { return inlineToHTML((block?.content as ShipEditorInlineNode[]) || []) || '<br>'; } getCodeText(block: ShipEditorBlock): string { return ((block.content as ShipEditorInlineNode[]) || []).map((node) => node.text || '').join(''); } getImageClass(block: ShipEditorBlock): string { const mode = block.attrs?.mode || 'content'; const size = block.attrs?.size || 'auto'; if (mode === 'content' || mode === 'theater') { return `sh-editor-img-${mode}`; } return `sh-editor-img-${mode} sh-editor-img-size-${size}`; } #saveAndRestoreSelection(action: () => void) { if (!this.#isBrowser || this.viewMode() === 'code') { action(); return; } const editor = this.editorRef()?.nativeElement; const saved = editor ? EditorSelection.read(editor) : null; action(); // Synchronous caret restoration — the action uses innerHTML which is // synchronous, so the new DOM nodes are immediately available. if (saved && editor) { EditorSelection.apply(editor, this.documentState(), saved.start, saved.end); } } charCount = signal<number>(0); wordCount = signal<number>(0); onChange: (value: ShipEditorValue) => void = () => {}; onTouched: () => void = () => {}; hostClasses = shipComponentClasses('editor', { color: this.color, variant: this.variant, readonly: this.readonly, }); #isWriting = false; #lastFormat: 'json' | 'html' | 'markdown' | null = null; /** Set the document state and increment the version counter for history deduplication. */ #setDocumentState(doc: ShipEditorDocument) { this.#docVersion++; this.documentState.set(doc); // Update word/char counts from the AST. This is the single source of truth for // programmatic doc changes (transactions, undo, paste, etc.). The onDOMInput() // handler provides instant counts from editor.textContent during typing. const text = getJSONText(doc).replace(/\\u00a0/g, ' ').trim(); this.charCount.set(text.length); this.wordCount.set(text === '' ? 0 : text.split(/\\s+/).filter((w) => w.length > 0).length); } /** Run a callback while suppressing feedback loops (DOM→model→DOM cycles). */ #runWithoutFeedback(fn: () => void) { this.#isWriting = true; try { fn(); } finally { this.#isWriting = false; } } /** Unified state commit: AST → signals → serialized value → DOM. Every document mutation should go through this single path to keep state, value, and DOM in sync and avoid forgotten guards. / #commitDocument( newDoc: ShipEditorDocument, options?: { /** Wrap in #runWithoutFeedback to suppress DOM→model→DOM loops. */ suppressFeedback?: boolean; /** Set #isInternalDOMUpdate to block stale selectionchange events. */ guardSelectionChange?: boolean; /** Preserve scroll position across the DOM re-render. */ preserveScroll?: boolean; /** Skip DOM render (caller handles it or no editor ref). */ skipRender?: boolean; } ) { const opts = { suppressFeedback: false, guardSelectionChange: false, preserveScroll: false, skipRender: false, ...options, }; if (opts.guardSelectionChange) this.#isInternalDOMUpdate = true; const commit = () => { this.#setDocumentState(newDoc); this.#updateValueFromState(); if (!opts.skipRender) { const editor = this.editorRef()?.nativeElement; if (editor) { const scrollTop = opts.preserveScroll ? editor.scrollTop : undefined; const scrollLeft = opts.preserveScroll ? editor.scrollLeft : undefined; this.#renderHTMLToDOM(jsonToHTML(newDoc, this.#registry)); if (scrollTop !== undefined) editor.scrollTop = scrollTop; if (scrollLeft !== undefined) editor.scrollLeft = scrollLeft; } } }; if (opts.suppressFeedback) { this.#runWithoutFeedback(commit); } else { commit(); } } /** Serialize an AST to the current format and push to the value signal. Centralises the format-branching logic used by #updateValueFromState, setHTML, setMarkdown, and setJSON. / #serializeASTToValue(ast: ShipEditorDocument): ShipEditorValue { const currentFormat = this.format(); if (currentFormat === 'json') { this.value.set(ast); this.#lastValueWrittenFromDOM = ast; this.onChange(ast); this.rawCodeValue.set(JSON.stringify(ast, null, 2)); return ast; } else { const html = jsonToHTML(ast, this.#registry); if (currentFormat === 'html') { this.value.set(html); this.#lastValueWrittenFromDOM = html; this.onChange(html); return html; } else if (currentFormat === 'markdown') { const md = htmlToMarkdown(html, this.#document, this.#registry); this.value.set(md); this.#lastValueWrittenFromDOM = md; this.onChange(md); this.rawCodeValue.set(md); return md; } } return null; } #linkModalFocusEffect = effect(() => { if (this.showLinkModal()) { const linkInput = this.linkInput(); if (linkInput) { linkInput.nativeElement.focus(); } } }); #imageModalFocusEffect = effect(() => { if (this.showImageModal()) { // Defer focus to next render so Angular has rendered the @if block afterNextRender( () => { const uploadBtn = this.uploadBtn(); const imageInput = this.imageInput(); if (this.imageUploadEnabled()) { if (uploadBtn) { uploadBtn.nativeElement.focus(); } } else { if (imageInput) { imageInput.nativeElement.focus(); } } }, { injector: this.#injector } ); } }); #valueSyncEffect = effect(() => { const val = this.value(); const editor = this.editorRef()?.nativeElement; if (editor && editor !== this.#lastEditorElement) { this.#lastEditorElement = editor; this.#lastValueWrittenFromDOM = undefined; } if (val === this.#lastValueWrittenFromDOM) return; this.#syncModelToDOM(val); }); #formatSwitchEffect = effect(() => { const fmt = this.format(); const prev = this.#lastFormat; this.#lastFormat = fmt; if (prev !== null && prev !== fmt) { const val = this.value(); let html = ''; if (prev === 'html' && typeof val === 'string') { html = val; } else if (prev === 'markdown' && typeof val === 'string') { html = markdownToHTML(val, this.#registry); } else if (prev === 'json' && Array.isArray(val)) { html = jsonToHTML(val, this.#registry); } let newValue: ShipEditorValue = ''; if (fmt === 'html') { newValue = html; } else if (fmt === 'markdown') { newValue = htmlToMarkdown(html, this.#document, this.#registry); } else if (fmt === 'json') { newValue = htmlToJSON(html, this.#document, this.#registry); } this.#runWithoutFeedback(() => { this.value.set(newValue); this.#lastValueWrittenFromDOM = newValue; this.onChange(newValue); if (fmt === 'markdown' && typeof newValue === 'string') { this.rawCodeValue.set(newValue); } else if (fmt === 'json' && Array.isArray(newValue)) { this.rawCodeValue.set(JSON.stringify(newValue, null, 2)); } else { this.rawCodeValue.set(html); } const ast = htmlToJSON(html, this.#document, this.#registry); this.#saveAndRestoreSelection(() => { this.#setDocumentState(ast); }); }); } }); // Word/char counts are updated synchronously in #setDocumentState() (for programmatic // changes) and in onDOMInput() (for instant typing feedback). No async effect needed. #toolbarVisibilityEffect = effect(() => { this.showFormats(); this.showBlocks(); this.showLists(); this.showAlignments(); this.showInsertions(); this.showHistory(); this.toolbar(); this.readonly(); this.#initializeToolbarTabindexes(); }); constructor() { // Register mark extensions once (these aren't configurable via input yet) this.#registry.registerDefaultMarks(); // Register block extensions from the input — defaults to defaultBlockExtensions effect(() => { const exts = this.extensions(); this.#registry.clearBlocks(); exts.forEach((ext) => this.#registry.registerBlock(ext)); }); } ngAfterViewInit() { this.#syncModelToDOM(this.value()); this.#registry.getAllBlocks().forEach((ext) => ext.onInit?.(this)); this.#registry.getAllMarks().forEach((ext) => ext.onInit?.(this)); } ngOnDestroy() { this.#savedRange = null; if (this.#valueUpdateTimeout) { clearTimeout(this.#valueUpdateTimeout); } this.#registry.getAllBlocks().forEach((ext) => ext.onDestroy?.(this)); this.#registry.getAllMarks().forEach((ext) => ext.onDestroy?.(this)); } writeValue(obj: ShipEditorValue): void { this.#runWithoutFeedback(() => { this.value.set(obj); this.#syncModelToDOM(obj); }); } registerOnChange(fn: (value: ShipEditorValue) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(_isDisabled: boolean): void { // CVA disabled state not supported — use the [readonly] signal input instead } #stripCompiledMarkup(html: string): string { if (!html) return ''; let clean = html .replace(/<!--[\\s\\S]*?-->/g, '') .replace(/\\s*_ngcontent-[a-z0-9-]+(?:=\"[^\"]*\"|='[^']*'|=[^\\s>]+)?/gi, '') .replace(/\\s*_nghost-[a-z0-9-]+(?:=\"[^\"]*\"|='[^']*'|=[^\\s>]+)?/gi, '') .replace(/\\s*ng-[a-z0-9-]+(?:=\"[^\"]*\"|='[^']*'|=[^\\s>]+)?/gi, '') .replace(/\\s*data-block-index=\"[0-9]+\"/gi, '') .replace(/\\s*data-item-index=\"[0-9]+\"/gi, ''); clean = clean.replace(/class=\"([^\"]*)sh-editor-active-block([^\"]*)\"/gi, (match, p1, p2) => { const remaining = (p1 + p2).replace(/\\s+/g, ' ').trim(); return remaining ? `class=\"${remaining}\"` : ''; }); // Remove any trailing spaces inside tag brackets (e.g. <p > -> <p>) clean = clean.replace(/\\s+(?=>)/g, ''); return clean; } #renderHTMLToDOM(html: string) { const editor = this.editorRef()?.nativeElement; if (!editor) return; editor.innerHTML = html; const doc = this.documentState(); const children = Array.from(editor.children); children.forEach((child, idx) => { child.setAttribute('data-block-index', idx.toString()); const tag = child.tagName.toLowerCase(); if (tag === 'ul' || tag === 'ol') { const items = Array.from(child.querySelectorAll(':scope > li')); items.forEach((item, itemIdx) => { item.setAttribute('data-item-index', itemIdx.toString()); }); } // Call extension onBlockRender hook for post-render DOM enhancements const block = doc[idx]; if (block) { const ext = this.#registry.getBlock(block.type); if (ext?.onBlockRender) { ext.onBlockRender(child as HTMLElement, block, idx); } } }); } #syncModelToDOM(val: ShipEditorValue) { let ast: ShipEditorDocument = []; let html = ''; if (val === null || val === undefined || val === '') { ast = [{ type: 'paragraph', content: [] }]; html = '<p><br></p>'; } else if (Array.isArray(val)) { ast = val; html = jsonToHTML(val, this.#registry); } else if (typeof val === 'string') { if (this.format() === 'markdown') { html = markdownToHTML(val, this.#registry); } else { html = val; } ast = htmlToJSON(html, this.#document, this.#registry); } const isNewValue = val !== this.#lastValueWrittenFromDOM; if (this.format() === 'markdown' && typeof val === 'string') { this.rawCodeValue.set(val); } else if (this.format() === 'json' && Array.isArray(val)) { this.rawCodeValue.set(JSON.stringify(val, null, 2)); } else { this.rawCodeValue.set(html); } this.#lastValueWrittenFromDOM = val; this.#saveAndRestoreSelection(() => { this.#setDocumentState(ast); this.#renderHTMLToDOM(html); }); if (isNewValue) { this.#historyStack = []; this.#historyIndex = -1; } if (this.#historyStack.length === 0) { this.#saveHistory(); } this.#updateHistoryStates(); } #updateValueFromDOM() { const editor = this.editorRef()?.nativeElement; if (!editor) return; const rawHtml = editor.innerHTML; // Phase 1: Build AST (Internal State) - Fast const initialAst = htmlToJSON(rawHtml, this.#document, this.#registry); let cleanHtml = this.#stripCompiledMarkup(jsonToHTML(initialAst, this.#registry)); if (cleanHtml === '' || cleanHtml === '<br>' || cleanHtml === '<p><br></p>') { cleanHtml = ''; } const ast = htmlToJSON(cleanHtml, this.#document, this.#registry); this.#runWithoutFeedback(() => { this.#saveAndRestoreSelection(() => { this.#setDocumentState(ast); }); // Phase 2: Serialize & Emit Value - Debounced for performance if (this.#valueUpdateTimeout) { clearTimeout(this.#valueUpdateTimeout); } const format = this.format(); // Heuristic: Use longer debounce for heavy markdown serialization on large docs const isHeavy = format === 'markdown' && rawHtml.length > 5000; const emitDelay = isHeavy ? 800 : 200; this.#valueUpdateTimeout = setTimeout(() => { this.#lastValueWrittenFromDOM = this.#serializeASTToValue(ast); this.#saveHistory(); }, emitDelay); }); } onDOMInput() { // Update internal metrics (character and word tracking) instantly via local evaluations const editor = this.editorRef()?.nativeElement; if (editor) { const textContent = editor.textContent || ''; this.charCount.set(textContent.length); this.wordCount.set(textContent.trim() === '' ? 0 : textContent.trim().split(/\\s+/).length); // Run extension onBlockRender for image blocks after browser-native DOM mutations const imgExt = this.#registry.getBlock('image'); if (imgExt?.onBlockRender) { const imgs = editor.querySelectorAll('img'); imgs.forEach((img) => { const blockEl = img.closest('[data-block-index]') as HTMLElement; if (blockEl) { const idx = parseInt(blockEl.getAttribute('data-block-index') || '0', 10); imgExt.onBlockRender!(blockEl, { type: 'image' }, idx); } }); } } const selection = window.getSelection(); let saveImmediately = false; if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const textNode = range.startContainer; if (textNode.nodeType === 3) { // Node.TEXT_NODE const char = textNode.textContent?.charAt(range.startOffset - 1); if (char && /[\\s.,!?;/]/.test(char)) { saveImmediately = true; } } } if (this.#typingTimeout) { clearTimeout(this.#typingTimeout); } const delay = saveImmediately ? 50 : 500; this.#typingTimeout = setTimeout(() => { this.#updateValueFromDOM(); }, delay); } onDOMBlur() { this.isFocused.set(false); this.onTouched(); // Flush any pending debounced updates immediately on blur if (this.#typingTimeout) { clearTimeout(this.#typingTimeout); this.#updateValueFromDOM(); } // We also need to ensure the Phase 2 (serialization) is flushed if (this.#valueUpdateTimeout) { clearTimeout(this.#valueUpdateTimeout); const ast = this.documentState(); this.#lastValueWrittenFromDOM = this.#serializeASTToValue(ast); } } onBeforeInput(event: InputEvent) { if (this.readonly() || this.viewMode() === 'code') return; const editor = this.editorRef()?.nativeElement; if (!editor) return; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const position = mapDOMPositionToLogical(editor, range.startContainer, range.startOffset); if (!position) return; const type = event.inputType; if (type === 'insertParagraph' || type === 'insertLineBreak') { event.preventDefault(); // Delegate to extension onBlockKeydown if the block type has one const currentBlock = this.documentState()[position.blockIndex]; if (currentBlock) { const ext = this.#registry.getBlock(currentBlock.type); if (ext?.onBlockKeydown) { const editorEl = this.editorRef()?.nativeElement; const blockEl = editorEl?.children[position.blockIndex] as HTMLElement; const ctx: ShipEditorBlockContext = { position, blockEl, doc: this.documentState(), }; const result = ext.onBlockKeydown(event, ctx); if (result !== false) { this.#updateStateAndCaret(result.doc, result.position); return; } } } const doc = this.documentState(); const { doc: newDoc, newPosition } = splitBlock(doc, position); this.#updateStateAndCaret(newDoc, newPosition); } else if (type === 'deleteContentBackward') { // Backspace // Intercept if caret is at the start of a block if (position.inlineIndex === 0 && position.offset === 0) { event.preventDefault(); const doc = this.documentState(); const { doc: newDoc, newPosition } = mergeBlocks(doc, position); this.#updateStateAndCaret(newDoc, newPosition); } } else if (type === 'deleteContentForward') { // Delete key — merge with the next block when caret is at the end. // Only intercept at block boundaries; let the browser handle mid-block deletes. const doc = this.documentState(); const block = doc[position.blockIndex]; if (block) { const content = (Array.isArray(block.content) ? block.content : []) as ShipEditorInlineNode[]; const lastIdx = Math.max(0, content.length - 1); const lastLen = content[lastIdx]?.text?.length || 0; const isAtEnd = position.inlineIndex >= lastIdx && position.offset >= lastLen; if (isAtEnd) { const result = mergeBlockForward(doc, position); event.preventDefault(); this.#updateStateAndCaret(result.doc, result.newPosition); } } } } #updateStateAndCaret(newDoc: ShipEditorDocument, newPosition: LogicalPosition) { this.#commitDocument(newDoc, { preserveScroll: true }); const editor = this.editorRef()?.nativeElement; if (editor) { // Synchronous caret restoration — #commitDocument uses innerHTML which // is synchronous, so the new DOM nodes are immediately available. EditorSelection.apply(editor, this.documentState(), newPosition, null, { scrollIntoView: true }); this.onSelectionChange(); } } #updateValueFromState() { this.#runWithoutFeedback(() => { this.#serializeASTToValue(this.documentState()); }); } onCodeInput(event: Event) { const textarea = event.target as HTMLTextAreaElement; this.rawCodeValue.set(textarea.value); } onCodeBlur(event: Event) { this.isFocused.set(false); const textarea = event.target as HTMLTextAreaElement; const codeVal = textarea.value; this.#runWithoutFeedback(() => { const currentFormat = this.format(); if (currentFormat === 'html') { this.value.set(codeVal); this.onChange(codeVal); } else if (currentFormat === 'markdown') { this.value.set(codeVal); this.onChange(codeVal); } else if (currentFormat === 'json') { try { const parsed = JSON.parse(codeVal); this.value.set(parsed); this.onChange(parsed); } catch { /* JSON parse error in code view — ignored */ } } }); this.onTouched(); } /** Valid commands for formatText(). Case-insensitive to match template usage.",
|
|
53
210
|
"defaultValue": "false"
|
|
54
211
|
},
|
|
55
212
|
{
|
|
@@ -130,10 +287,16 @@
|
|
|
130
287
|
"description": "",
|
|
131
288
|
"defaultValue": "true"
|
|
132
289
|
},
|
|
290
|
+
{
|
|
291
|
+
"name": "extensions",
|
|
292
|
+
"type": "ShipEditorBlockExtension[]",
|
|
293
|
+
"description": "Block extensions to use. Defaults to the built-in set. Override to customize behavior or add custom blocks. @example ```typescript import { defaultBlockExtensions, configureExtension, imageBlockExtension } from 'ship-ui/ship-editor'; myExtensions = [ ...defaultBlockExtensions.filter(e => e.type !== 'image'), configureExtension(imageBlockExtension, { defaultMode: 'content', defaultSize: 'auto' }), ]; ```",
|
|
294
|
+
"defaultValue": "defaultBlockExtensions"
|
|
295
|
+
},
|
|
133
296
|
{
|
|
134
297
|
"name": "value",
|
|
135
298
|
"type": "string | ShipEditorDocument | null",
|
|
136
|
-
"description": "",
|
|
299
|
+
"description": "Block extensions to use. Defaults to the built-in set. Override to customize behavior or add custom blocks. @example ```typescript import { defaultBlockExtensions, configureExtension, imageBlockExtension } from 'ship-ui/ship-editor'; myExtensions = [ ...defaultBlockExtensions.filter(e => e.type !== 'image'), configureExtension(imageBlockExtension, { defaultMode: 'content', defaultSize: 'auto' }), ]; ``` / extensions = input<ShipEditorBlockExtension[]>(defaultBlockExtensions); #selectedImage = signal<HTMLImageElement | null>(null); imgMode = signal<'content' | 'theater' | 'float' | 'custom'>('content'); imgSize = signal<'auto' | 'small' | 'medium' | 'large'>('auto'); /** Tracks the DOM element with an extension-driven activeClassName applied. */ #activeBlockEl: HTMLElement | null = null; viewMode = signal<'design' | 'code'>('design'); isFocused = signal<boolean>(false); showLinkModal = signal<boolean>(false); showImageModal = signal<boolean>(false); rawCodeValue = signal<string>(''); showBlockMenu = signal<boolean>(false); isBold = signal<boolean>(false); isItalic = signal<boolean>(false); isUnderline = signal<boolean>(false); isStrike = signal<boolean>(false); align = signal<'left' | 'center' | 'right'>('left'); activeBlock = signal<string>('p'); canUndo = signal<boolean>(false); canRedo = signal<boolean>(false); #savedRange: Range | null = null; documentState = signal<ShipEditorDocument>([]); getBlockInlineHTML(block: any): string { return inlineToHTML((block?.content as ShipEditorInlineNode[]) || []) || '<br>'; } getCodeText(block: ShipEditorBlock): string { return ((block.content as ShipEditorInlineNode[]) || []).map((node) => node.text || '').join(''); } getImageClass(block: ShipEditorBlock): string { const mode = block.attrs?.mode || 'content'; const size = block.attrs?.size || 'auto'; if (mode === 'content' || mode === 'theater') { return `sh-editor-img-${mode}`; } return `sh-editor-img-${mode} sh-editor-img-size-${size}`; } #saveAndRestoreSelection(action: () => void) { if (!this.#isBrowser || this.viewMode() === 'code') { action(); return; } const editor = this.editorRef()?.nativeElement; const saved = editor ? EditorSelection.read(editor) : null; action(); // Synchronous caret restoration — the action uses innerHTML which is // synchronous, so the new DOM nodes are immediately available. if (saved && editor) { EditorSelection.apply(editor, this.documentState(), saved.start, saved.end); } } charCount = signal<number>(0); wordCount = signal<number>(0); onChange: (value: ShipEditorValue) => void = () => {}; onTouched: () => void = () => {}; hostClasses = shipComponentClasses('editor', { color: this.color, variant: this.variant, readonly: this.readonly, }); #isWriting = false; #lastFormat: 'json' | 'html' | 'markdown' | null = null; /** Set the document state and increment the version counter for history deduplication. */ #setDocumentState(doc: ShipEditorDocument) { this.#docVersion++; this.documentState.set(doc); // Update word/char counts from the AST. This is the single source of truth for // programmatic doc changes (transactions, undo, paste, etc.). The onDOMInput() // handler provides instant counts from editor.textContent during typing. const text = getJSONText(doc).replace(/\\u00a0/g, ' ').trim(); this.charCount.set(text.length); this.wordCount.set(text === '' ? 0 : text.split(/\\s+/).filter((w) => w.length > 0).length); } /** Run a callback while suppressing feedback loops (DOM→model→DOM cycles). */ #runWithoutFeedback(fn: () => void) { this.#isWriting = true; try { fn(); } finally { this.#isWriting = false; } } /** Unified state commit: AST → signals → serialized value → DOM. Every document mutation should go through this single path to keep state, value, and DOM in sync and avoid forgotten guards. / #commitDocument( newDoc: ShipEditorDocument, options?: { /** Wrap in #runWithoutFeedback to suppress DOM→model→DOM loops. */ suppressFeedback?: boolean; /** Set #isInternalDOMUpdate to block stale selectionchange events. */ guardSelectionChange?: boolean; /** Preserve scroll position across the DOM re-render. */ preserveScroll?: boolean; /** Skip DOM render (caller handles it or no editor ref). */ skipRender?: boolean; } ) { const opts = { suppressFeedback: false, guardSelectionChange: false, preserveScroll: false, skipRender: false, ...options, }; if (opts.guardSelectionChange) this.#isInternalDOMUpdate = true; const commit = () => { this.#setDocumentState(newDoc); this.#updateValueFromState(); if (!opts.skipRender) { const editor = this.editorRef()?.nativeElement; if (editor) { const scrollTop = opts.preserveScroll ? editor.scrollTop : undefined; const scrollLeft = opts.preserveScroll ? editor.scrollLeft : undefined; this.#renderHTMLToDOM(jsonToHTML(newDoc, this.#registry)); if (scrollTop !== undefined) editor.scrollTop = scrollTop; if (scrollLeft !== undefined) editor.scrollLeft = scrollLeft; } } }; if (opts.suppressFeedback) { this.#runWithoutFeedback(commit); } else { commit(); } } /** Serialize an AST to the current format and push to the value signal. Centralises the format-branching logic used by #updateValueFromState, setHTML, setMarkdown, and setJSON. / #serializeASTToValue(ast: ShipEditorDocument): ShipEditorValue { const currentFormat = this.format(); if (currentFormat === 'json') { this.value.set(ast); this.#lastValueWrittenFromDOM = ast; this.onChange(ast); this.rawCodeValue.set(JSON.stringify(ast, null, 2)); return ast; } else { const html = jsonToHTML(ast, this.#registry); if (currentFormat === 'html') { this.value.set(html); this.#lastValueWrittenFromDOM = html; this.onChange(html); return html; } else if (currentFormat === 'markdown') { const md = htmlToMarkdown(html, this.#document, this.#registry); this.value.set(md); this.#lastValueWrittenFromDOM = md; this.onChange(md); this.rawCodeValue.set(md); return md; } } return null; } #linkModalFocusEffect = effect(() => { if (this.showLinkModal()) { const linkInput = this.linkInput(); if (linkInput) { linkInput.nativeElement.focus(); } } }); #imageModalFocusEffect = effect(() => { if (this.showImageModal()) { // Defer focus to next render so Angular has rendered the @if block afterNextRender( () => { const uploadBtn = this.uploadBtn(); const imageInput = this.imageInput(); if (this.imageUploadEnabled()) { if (uploadBtn) { uploadBtn.nativeElement.focus(); } } else { if (imageInput) { imageInput.nativeElement.focus(); } } }, { injector: this.#injector } ); } }); #valueSyncEffect = effect(() => { const val = this.value(); const editor = this.editorRef()?.nativeElement; if (editor && editor !== this.#lastEditorElement) { this.#lastEditorElement = editor; this.#lastValueWrittenFromDOM = undefined; } if (val === this.#lastValueWrittenFromDOM) return; this.#syncModelToDOM(val); }); #formatSwitchEffect = effect(() => { const fmt = this.format(); const prev = this.#lastFormat; this.#lastFormat = fmt; if (prev !== null && prev !== fmt) { const val = this.value(); let html = ''; if (prev === 'html' && typeof val === 'string') { html = val; } else if (prev === 'markdown' && typeof val === 'string') { html = markdownToHTML(val, this.#registry); } else if (prev === 'json' && Array.isArray(val)) { html = jsonToHTML(val, this.#registry); } let newValue: ShipEditorValue = ''; if (fmt === 'html') { newValue = html; } else if (fmt === 'markdown') { newValue = htmlToMarkdown(html, this.#document, this.#registry); } else if (fmt === 'json') { newValue = htmlToJSON(html, this.#document, this.#registry); } this.#runWithoutFeedback(() => { this.value.set(newValue); this.#lastValueWrittenFromDOM = newValue; this.onChange(newValue); if (fmt === 'markdown' && typeof newValue === 'string') { this.rawCodeValue.set(newValue); } else if (fmt === 'json' && Array.isArray(newValue)) { this.rawCodeValue.set(JSON.stringify(newValue, null, 2)); } else { this.rawCodeValue.set(html); } const ast = htmlToJSON(html, this.#document, this.#registry); this.#saveAndRestoreSelection(() => { this.#setDocumentState(ast); }); }); } }); // Word/char counts are updated synchronously in #setDocumentState() (for programmatic // changes) and in onDOMInput() (for instant typing feedback). No async effect needed. #toolbarVisibilityEffect = effect(() => { this.showFormats(); this.showBlocks(); this.showLists(); this.showAlignments(); this.showInsertions(); this.showHistory(); this.toolbar(); this.readonly(); this.#initializeToolbarTabindexes(); }); constructor() { // Register mark extensions once (these aren't configurable via input yet) this.#registry.registerDefaultMarks(); // Register block extensions from the input — defaults to defaultBlockExtensions effect(() => { const exts = this.extensions(); this.#registry.clearBlocks(); exts.forEach((ext) => this.#registry.registerBlock(ext)); }); } ngAfterViewInit() { this.#syncModelToDOM(this.value()); this.#registry.getAllBlocks().forEach((ext) => ext.onInit?.(this)); this.#registry.getAllMarks().forEach((ext) => ext.onInit?.(this)); } ngOnDestroy() { this.#savedRange = null; if (this.#valueUpdateTimeout) { clearTimeout(this.#valueUpdateTimeout); } this.#registry.getAllBlocks().forEach((ext) => ext.onDestroy?.(this)); this.#registry.getAllMarks().forEach((ext) => ext.onDestroy?.(this)); } writeValue(obj: ShipEditorValue): void { this.#runWithoutFeedback(() => { this.value.set(obj); this.#syncModelToDOM(obj); }); } registerOnChange(fn: (value: ShipEditorValue) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(_isDisabled: boolean): void { // CVA disabled state not supported — use the [readonly] signal input instead } #stripCompiledMarkup(html: string): string { if (!html) return ''; let clean = html .replace(/<!--[\\s\\S]*?-->/g, '') .replace(/\\s*_ngcontent-[a-z0-9-]+(?:=\"[^\"]*\"|='[^']*'|=[^\\s>]+)?/gi, '') .replace(/\\s*_nghost-[a-z0-9-]+(?:=\"[^\"]*\"|='[^']*'|=[^\\s>]+)?/gi, '') .replace(/\\s*ng-[a-z0-9-]+(?:=\"[^\"]*\"|='[^']*'|=[^\\s>]+)?/gi, '') .replace(/\\s*data-block-index=\"[0-9]+\"/gi, '') .replace(/\\s*data-item-index=\"[0-9]+\"/gi, ''); clean = clean.replace(/class=\"([^\"]*)sh-editor-active-block([^\"]*)\"/gi, (match, p1, p2) => { const remaining = (p1 + p2).replace(/\\s+/g, ' ').trim(); return remaining ? `class=\"${remaining}\"` : ''; }); // Remove any trailing spaces inside tag brackets (e.g. <p > -> <p>) clean = clean.replace(/\\s+(?=>)/g, ''); return clean; } #renderHTMLToDOM(html: string) { const editor = this.editorRef()?.nativeElement; if (!editor) return; editor.innerHTML = html; const doc = this.documentState(); const children = Array.from(editor.children); children.forEach((child, idx) => { child.setAttribute('data-block-index', idx.toString()); const tag = child.tagName.toLowerCase(); if (tag === 'ul' || tag === 'ol') { const items = Array.from(child.querySelectorAll(':scope > li')); items.forEach((item, itemIdx) => { item.setAttribute('data-item-index', itemIdx.toString()); }); } // Call extension onBlockRender hook for post-render DOM enhancements const block = doc[idx]; if (block) { const ext = this.#registry.getBlock(block.type); if (ext?.onBlockRender) { ext.onBlockRender(child as HTMLElement, block, idx); } } }); } #syncModelToDOM(val: ShipEditorValue) { let ast: ShipEditorDocument = []; let html = ''; if (val === null || val === undefined || val === '') { ast = [{ type: 'paragraph', content: [] }]; html = '<p><br></p>'; } else if (Array.isArray(val)) { ast = val; html = jsonToHTML(val, this.#registry); } else if (typeof val === 'string') { if (this.format() === 'markdown') { html = markdownToHTML(val, this.#registry); } else { html = val; } ast = htmlToJSON(html, this.#document, this.#registry); } const isNewValue = val !== this.#lastValueWrittenFromDOM; if (this.format() === 'markdown' && typeof val === 'string') { this.rawCodeValue.set(val); } else if (this.format() === 'json' && Array.isArray(val)) { this.rawCodeValue.set(JSON.stringify(val, null, 2)); } else { this.rawCodeValue.set(html); } this.#lastValueWrittenFromDOM = val; this.#saveAndRestoreSelection(() => { this.#setDocumentState(ast); this.#renderHTMLToDOM(html); }); if (isNewValue) { this.#historyStack = []; this.#historyIndex = -1; } if (this.#historyStack.length === 0) { this.#saveHistory(); } this.#updateHistoryStates(); } #updateValueFromDOM() { const editor = this.editorRef()?.nativeElement; if (!editor) return; const rawHtml = editor.innerHTML; // Phase 1: Build AST (Internal State) - Fast const initialAst = htmlToJSON(rawHtml, this.#document, this.#registry); let cleanHtml = this.#stripCompiledMarkup(jsonToHTML(initialAst, this.#registry)); if (cleanHtml === '' || cleanHtml === '<br>' || cleanHtml === '<p><br></p>') { cleanHtml = ''; } const ast = htmlToJSON(cleanHtml, this.#document, this.#registry); this.#runWithoutFeedback(() => { this.#saveAndRestoreSelection(() => { this.#setDocumentState(ast); }); // Phase 2: Serialize & Emit Value - Debounced for performance if (this.#valueUpdateTimeout) { clearTimeout(this.#valueUpdateTimeout); } const format = this.format(); // Heuristic: Use longer debounce for heavy markdown serialization on large docs const isHeavy = format === 'markdown' && rawHtml.length > 5000; const emitDelay = isHeavy ? 800 : 200; this.#valueUpdateTimeout = setTimeout(() => { this.#lastValueWrittenFromDOM = this.#serializeASTToValue(ast); this.#saveHistory(); }, emitDelay); }); } onDOMInput() { // Update internal metrics (character and word tracking) instantly via local evaluations const editor = this.editorRef()?.nativeElement; if (editor) { const textContent = editor.textContent || ''; this.charCount.set(textContent.length); this.wordCount.set(textContent.trim() === '' ? 0 : textContent.trim().split(/\\s+/).length); // Run extension onBlockRender for image blocks after browser-native DOM mutations const imgExt = this.#registry.getBlock('image'); if (imgExt?.onBlockRender) { const imgs = editor.querySelectorAll('img'); imgs.forEach((img) => { const blockEl = img.closest('[data-block-index]') as HTMLElement; if (blockEl) { const idx = parseInt(blockEl.getAttribute('data-block-index') || '0', 10); imgExt.onBlockRender!(blockEl, { type: 'image' }, idx); } }); } } const selection = window.getSelection(); let saveImmediately = false; if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const textNode = range.startContainer; if (textNode.nodeType === 3) { // Node.TEXT_NODE const char = textNode.textContent?.charAt(range.startOffset - 1); if (char && /[\\s.,!?;/]/.test(char)) { saveImmediately = true; } } } if (this.#typingTimeout) { clearTimeout(this.#typingTimeout); } const delay = saveImmediately ? 50 : 500; this.#typingTimeout = setTimeout(() => { this.#updateValueFromDOM(); }, delay); } onDOMBlur() { this.isFocused.set(false); this.onTouched(); // Flush any pending debounced updates immediately on blur if (this.#typingTimeout) { clearTimeout(this.#typingTimeout); this.#updateValueFromDOM(); } // We also need to ensure the Phase 2 (serialization) is flushed if (this.#valueUpdateTimeout) { clearTimeout(this.#valueUpdateTimeout); const ast = this.documentState(); this.#lastValueWrittenFromDOM = this.#serializeASTToValue(ast); } } onBeforeInput(event: InputEvent) { if (this.readonly() || this.viewMode() === 'code') return; const editor = this.editorRef()?.nativeElement; if (!editor) return; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const position = mapDOMPositionToLogical(editor, range.startContainer, range.startOffset); if (!position) return; const type = event.inputType; if (type === 'insertParagraph' || type === 'insertLineBreak') { event.preventDefault(); // Delegate to extension onBlockKeydown if the block type has one const currentBlock = this.documentState()[position.blockIndex]; if (currentBlock) { const ext = this.#registry.getBlock(currentBlock.type); if (ext?.onBlockKeydown) { const editorEl = this.editorRef()?.nativeElement; const blockEl = editorEl?.children[position.blockIndex] as HTMLElement; const ctx: ShipEditorBlockContext = { position, blockEl, doc: this.documentState(), }; const result = ext.onBlockKeydown(event, ctx); if (result !== false) { this.#updateStateAndCaret(result.doc, result.position); return; } } } const doc = this.documentState(); const { doc: newDoc, newPosition } = splitBlock(doc, position); this.#updateStateAndCaret(newDoc, newPosition); } else if (type === 'deleteContentBackward') { // Backspace // Intercept if caret is at the start of a block if (position.inlineIndex === 0 && position.offset === 0) { event.preventDefault(); const doc = this.documentState(); const { doc: newDoc, newPosition } = mergeBlocks(doc, position); this.#updateStateAndCaret(newDoc, newPosition); } } else if (type === 'deleteContentForward') { // Delete key — merge with the next block when caret is at the end. // Only intercept at block boundaries; let the browser handle mid-block deletes. const doc = this.documentState(); const block = doc[position.blockIndex]; if (block) { const content = (Array.isArray(block.content) ? block.content : []) as ShipEditorInlineNode[]; const lastIdx = Math.max(0, content.length - 1); const lastLen = content[lastIdx]?.text?.length || 0; const isAtEnd = position.inlineIndex >= lastIdx && position.offset >= lastLen; if (isAtEnd) { const result = mergeBlockForward(doc, position); event.preventDefault(); this.#updateStateAndCaret(result.doc, result.newPosition); } } } } #updateStateAndCaret(newDoc: ShipEditorDocument, newPosition: LogicalPosition) { this.#commitDocument(newDoc, { preserveScroll: true }); const editor = this.editorRef()?.nativeElement; if (editor) { // Synchronous caret restoration — #commitDocument uses innerHTML which // is synchronous, so the new DOM nodes are immediately available. EditorSelection.apply(editor, this.documentState(), newPosition, null, { scrollIntoView: true }); this.onSelectionChange(); } } #updateValueFromState() { this.#runWithoutFeedback(() => { this.#serializeASTToValue(this.documentState()); }); } onCodeInput(event: Event) { const textarea = event.target as HTMLTextAreaElement; this.rawCodeValue.set(textarea.value); } onCodeBlur(event: Event) { this.isFocused.set(false); const textarea = event.target as HTMLTextAreaElement; const codeVal = textarea.value; this.#runWithoutFeedback(() => { const currentFormat = this.format(); if (currentFormat === 'html') { this.value.set(codeVal); this.onChange(codeVal); } else if (currentFormat === 'markdown') { this.value.set(codeVal); this.onChange(codeVal); } else if (currentFormat === 'json') { try { const parsed = JSON.parse(codeVal); this.value.set(parsed); this.onChange(parsed); } catch { /* JSON parse error in code view — ignored */ } } }); this.onTouched(); } /** Valid commands for formatText(). Case-insensitive to match template usage.",
|
|
137
300
|
"defaultValue": "''"
|
|
138
301
|
}
|
|
139
302
|
],
|
|
@@ -146,20 +309,50 @@
|
|
|
146
309
|
],
|
|
147
310
|
"methods": [
|
|
148
311
|
{
|
|
149
|
-
"name": "
|
|
150
|
-
"parameters": "
|
|
151
|
-
"returnType": "
|
|
312
|
+
"name": "getCodeText",
|
|
313
|
+
"parameters": "block: ShipEditorBlock",
|
|
314
|
+
"returnType": "string",
|
|
315
|
+
"description": ""
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
"name": "return",
|
|
319
|
+
"parameters": "(block.content as ShipEditorInlineNode[]) || []).map((node) => node.text || '').join('');\n }\n\n getImageClass(block: ShipEditorBlock",
|
|
320
|
+
"returnType": "string",
|
|
321
|
+
"description": ""
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"name": "action",
|
|
325
|
+
"parameters": ");\n return;\n }\n\n const editor = this.editorRef()?.nativeElement;\n const saved = editor ? EditorSelection.read(editor",
|
|
326
|
+
"returnType": "null;\n\n action();\n\n // Synchronous caret restoration — the action uses innerHTML which is\n // synchronous, so the new DOM nodes are immediately available.\n if (saved && editor)",
|
|
327
|
+
"description": ""
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"name": "fn",
|
|
331
|
+
"parameters": ");\n } finally {\n this.#isWriting = false;\n }\n }\n\n /**\n * Unified state commit: AST → signals → serialized value → DOM.\n * Every document mutation should go through this single path to keep\n * state, value, and DOM in sync and avoid forgotten guards.\n */\n #commitDocument(\n newDoc: ShipEditorDocument,\n options?: {\n /** Wrap in #runWithoutFeedback to suppress DOM→model→DOM loops. */\n suppressFeedback?: boolean;\n /** Set #isInternalDOMUpdate to block stale selectionchange events. */\n guardSelectionChange?: boolean;\n /** Preserve scroll position across the DOM re-render. */\n preserveScroll?: boolean;\n /** Skip DOM render (caller handles it or no editor ref). */\n skipRender?: boolean;\n }",
|
|
332
|
+
"returnType": "any",
|
|
333
|
+
"description": "Block extensions to use. Defaults to the built-in set. Override to customize behavior or add custom blocks. @example ```typescript import { defaultBlockExtensions, configureExtension, imageBlockExtension } from 'ship-ui/ship-editor'; myExtensions = [ ...defaultBlockExtensions.filter(e => e.type !== 'image'), configureExtension(imageBlockExtension, { defaultMode: 'content', defaultSize: 'auto' }), ]; ``` / extensions = input<ShipEditorBlockExtension[]>(defaultBlockExtensions); #selectedImage = signal<HTMLImageElement | null>(null); imgMode = signal<'content' | 'theater' | 'float' | 'custom'>('content'); imgSize = signal<'auto' | 'small' | 'medium' | 'large'>('auto'); /** Tracks the DOM element with an extension-driven activeClassName applied. */ #activeBlockEl: HTMLElement | null = null; viewMode = signal<'design' | 'code'>('design'); isFocused = signal<boolean>(false); showLinkModal = signal<boolean>(false); showImageModal = signal<boolean>(false); rawCodeValue = signal<string>(''); showBlockMenu = signal<boolean>(false); isBold = signal<boolean>(false); isItalic = signal<boolean>(false); isUnderline = signal<boolean>(false); isStrike = signal<boolean>(false); align = signal<'left' | 'center' | 'right'>('left'); activeBlock = signal<string>('p'); canUndo = signal<boolean>(false); canRedo = signal<boolean>(false); #savedRange: Range | null = null; documentState = signal<ShipEditorDocument>([]); getBlockInlineHTML(block: any): string { return inlineToHTML((block?.content as ShipEditorInlineNode[]) || []) || '<br>'; } getCodeText(block: ShipEditorBlock): string { return ((block.content as ShipEditorInlineNode[]) || []).map((node) => node.text || '').join(''); } getImageClass(block: ShipEditorBlock): string { const mode = block.attrs?.mode || 'content'; const size = block.attrs?.size || 'auto'; if (mode === 'content' || mode === 'theater') { return `sh-editor-img-${mode}`; } return `sh-editor-img-${mode} sh-editor-img-size-${size}`; } #saveAndRestoreSelection(action: () => void) { if (!this.#isBrowser || this.viewMode() === 'code') { action(); return; } const editor = this.editorRef()?.nativeElement; const saved = editor ? EditorSelection.read(editor) : null; action(); // Synchronous caret restoration — the action uses innerHTML which is // synchronous, so the new DOM nodes are immediately available. if (saved && editor) { EditorSelection.apply(editor, this.documentState(), saved.start, saved.end); } } charCount = signal<number>(0); wordCount = signal<number>(0); onChange: (value: ShipEditorValue) => void = () => {}; onTouched: () => void = () => {}; hostClasses = shipComponentClasses('editor', { color: this.color, variant: this.variant, readonly: this.readonly, }); #isWriting = false; #lastFormat: 'json' | 'html' | 'markdown' | null = null; /** Set the document state and increment the version counter for history deduplication. */ #setDocumentState(doc: ShipEditorDocument) { this.#docVersion++; this.documentState.set(doc); // Update word/char counts from the AST. This is the single source of truth for // programmatic doc changes (transactions, undo, paste, etc.). The onDOMInput() // handler provides instant counts from editor.textContent during typing. const text = getJSONText(doc).replace(/\\u00a0/g, ' ').trim(); this.charCount.set(text.length); this.wordCount.set(text === '' ? 0 : text.split(/\\s+/).filter((w) => w.length > 0).length); } /** Run a callback while suppressing feedback loops (DOM→model→DOM cycles)."
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"name": "commit",
|
|
337
|
+
"parameters": ");\n }\n }\n\n /**\n * Serialize an AST to the current format and push to the value signal.\n * Centralises the format-branching logic used by #updateValueFromState,\n * setHTML, setMarkdown, and setJSON.\n */\n #serializeASTToValue(ast: ShipEditorDocument",
|
|
338
|
+
"returnType": "ShipEditorValue",
|
|
339
|
+
"description": "Block extensions to use. Defaults to the built-in set. Override to customize behavior or add custom blocks. @example ```typescript import { defaultBlockExtensions, configureExtension, imageBlockExtension } from 'ship-ui/ship-editor'; myExtensions = [ ...defaultBlockExtensions.filter(e => e.type !== 'image'), configureExtension(imageBlockExtension, { defaultMode: 'content', defaultSize: 'auto' }), ]; ``` / extensions = input<ShipEditorBlockExtension[]>(defaultBlockExtensions); #selectedImage = signal<HTMLImageElement | null>(null); imgMode = signal<'content' | 'theater' | 'float' | 'custom'>('content'); imgSize = signal<'auto' | 'small' | 'medium' | 'large'>('auto'); /** Tracks the DOM element with an extension-driven activeClassName applied. */ #activeBlockEl: HTMLElement | null = null; viewMode = signal<'design' | 'code'>('design'); isFocused = signal<boolean>(false); showLinkModal = signal<boolean>(false); showImageModal = signal<boolean>(false); rawCodeValue = signal<string>(''); showBlockMenu = signal<boolean>(false); isBold = signal<boolean>(false); isItalic = signal<boolean>(false); isUnderline = signal<boolean>(false); isStrike = signal<boolean>(false); align = signal<'left' | 'center' | 'right'>('left'); activeBlock = signal<string>('p'); canUndo = signal<boolean>(false); canRedo = signal<boolean>(false); #savedRange: Range | null = null; documentState = signal<ShipEditorDocument>([]); getBlockInlineHTML(block: any): string { return inlineToHTML((block?.content as ShipEditorInlineNode[]) || []) || '<br>'; } getCodeText(block: ShipEditorBlock): string { return ((block.content as ShipEditorInlineNode[]) || []).map((node) => node.text || '').join(''); } getImageClass(block: ShipEditorBlock): string { const mode = block.attrs?.mode || 'content'; const size = block.attrs?.size || 'auto'; if (mode === 'content' || mode === 'theater') { return `sh-editor-img-${mode}`; } return `sh-editor-img-${mode} sh-editor-img-size-${size}`; } #saveAndRestoreSelection(action: () => void) { if (!this.#isBrowser || this.viewMode() === 'code') { action(); return; } const editor = this.editorRef()?.nativeElement; const saved = editor ? EditorSelection.read(editor) : null; action(); // Synchronous caret restoration — the action uses innerHTML which is // synchronous, so the new DOM nodes are immediately available. if (saved && editor) { EditorSelection.apply(editor, this.documentState(), saved.start, saved.end); } } charCount = signal<number>(0); wordCount = signal<number>(0); onChange: (value: ShipEditorValue) => void = () => {}; onTouched: () => void = () => {}; hostClasses = shipComponentClasses('editor', { color: this.color, variant: this.variant, readonly: this.readonly, }); #isWriting = false; #lastFormat: 'json' | 'html' | 'markdown' | null = null; /** Set the document state and increment the version counter for history deduplication. */ #setDocumentState(doc: ShipEditorDocument) { this.#docVersion++; this.documentState.set(doc); // Update word/char counts from the AST. This is the single source of truth for // programmatic doc changes (transactions, undo, paste, etc.). The onDOMInput() // handler provides instant counts from editor.textContent during typing. const text = getJSONText(doc).replace(/\\u00a0/g, ' ').trim(); this.charCount.set(text.length); this.wordCount.set(text === '' ? 0 : text.split(/\\s+/).filter((w) => w.length > 0).length); } /** Run a callback while suppressing feedback loops (DOM→model→DOM cycles). */ #runWithoutFeedback(fn: () => void) { this.#isWriting = true; try { fn(); } finally { this.#isWriting = false; } } /** Unified state commit: AST → signals → serialized value → DOM. Every document mutation should go through this single path to keep state, value, and DOM in sync and avoid forgotten guards."
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
"name": "afterNextRender",
|
|
343
|
+
"parameters": "() => {\n if (nextMode === 'design'",
|
|
344
|
+
"returnType": "any",
|
|
152
345
|
"description": ""
|
|
153
346
|
},
|
|
154
347
|
{
|
|
155
348
|
"name": "effect",
|
|
156
|
-
"parameters": "() => {\n this.
|
|
349
|
+
"parameters": "() => {\n const exts = this.extensions();\n this.#registry.clearBlocks();\n exts.forEach((ext) => this.#registry.registerBlock(ext));\n });\n }\n\n ngAfterViewInit(",
|
|
157
350
|
"returnType": "any",
|
|
158
351
|
"description": ""
|
|
159
352
|
},
|
|
160
353
|
{
|
|
161
354
|
"name": "clearTimeout",
|
|
162
|
-
"parameters": "this.#typingTimeout);\n }\n\n if (
|
|
355
|
+
"parameters": "this.#typingTimeout);\n this.#typingTimeout = undefined;\n this.#updateValueFromDOM();\n this.#saveHistory();\n }\n\n this.#restoreSelection();\n\n const selection = window.getSelection();\n if (selection && selection.rangeCount > 0",
|
|
163
356
|
"returnType": "any",
|
|
164
357
|
"description": ""
|
|
165
358
|
},
|
|
@@ -177,22 +370,10 @@
|
|
|
177
370
|
},
|
|
178
371
|
{
|
|
179
372
|
"name": "setDisabledState",
|
|
180
|
-
"parameters": "
|
|
373
|
+
"parameters": "_isDisabled: boolean",
|
|
181
374
|
"returnType": "void",
|
|
182
375
|
"description": ""
|
|
183
376
|
},
|
|
184
|
-
{
|
|
185
|
-
"name": "onDOMInput",
|
|
186
|
-
"parameters": "",
|
|
187
|
-
"returnType": "any",
|
|
188
|
-
"description": ""
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
"name": "onDOMBlur",
|
|
192
|
-
"parameters": "",
|
|
193
|
-
"returnType": "any",
|
|
194
|
-
"description": ""
|
|
195
|
-
},
|
|
196
377
|
{
|
|
197
378
|
"name": "onCodeInput",
|
|
198
379
|
"parameters": "event: Event",
|
|
@@ -212,20 +393,8 @@
|
|
|
212
393
|
"description": ""
|
|
213
394
|
},
|
|
214
395
|
{
|
|
215
|
-
"name": "
|
|
216
|
-
"parameters": "
|
|
217
|
-
"returnType": "any",
|
|
218
|
-
"description": ""
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
"name": "while",
|
|
222
|
-
"parameters": "node",
|
|
223
|
-
"returnType": "any",
|
|
224
|
-
"description": ""
|
|
225
|
-
},
|
|
226
|
-
{
|
|
227
|
-
"name": "toggleLink",
|
|
228
|
-
"parameters": "url: string",
|
|
396
|
+
"name": "handler",
|
|
397
|
+
"parameters": "this, value);\n }\n }\n\n runTransaction(\n action: (\n doc: ShipEditorDocument,\n selection: { start: LogicalPosition; end: LogicalPosition }\n ) =>\n | {\n doc?: ShipEditorDocument;\n selectionShift?: {\n start: { blockIndex: number; listItemIndex?: number };\n end: { blockIndex: number; listItemIndex?: number };\n };\n }\n | ShipEditorDocument\n | void\n | null",
|
|
229
398
|
"returnType": "any",
|
|
230
399
|
"description": ""
|
|
231
400
|
},
|
|
@@ -235,18 +404,6 @@
|
|
|
235
404
|
"returnType": "any",
|
|
236
405
|
"description": ""
|
|
237
406
|
},
|
|
238
|
-
{
|
|
239
|
-
"name": "toggleList",
|
|
240
|
-
"parameters": "listType: 'ul' | 'ol'",
|
|
241
|
-
"returnType": "any",
|
|
242
|
-
"description": ""
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
"name": "insertHorizontalRule",
|
|
246
|
-
"parameters": "",
|
|
247
|
-
"returnType": "any",
|
|
248
|
-
"description": ""
|
|
249
|
-
},
|
|
250
407
|
{
|
|
251
408
|
"name": "setAlign",
|
|
252
409
|
"parameters": "direction: 'left' | 'center' | 'right'",
|
|
@@ -278,26 +435,20 @@
|
|
|
278
435
|
"description": ""
|
|
279
436
|
},
|
|
280
437
|
{
|
|
281
|
-
"name": "
|
|
282
|
-
"parameters": "",
|
|
283
|
-
"returnType": "any",
|
|
284
|
-
"description": ""
|
|
285
|
-
},
|
|
286
|
-
{
|
|
287
|
-
"name": "applyLink",
|
|
288
|
-
"parameters": "url: string",
|
|
438
|
+
"name": "while",
|
|
439
|
+
"parameters": "node && node !== editorEl",
|
|
289
440
|
"returnType": "any",
|
|
290
441
|
"description": ""
|
|
291
442
|
},
|
|
292
443
|
{
|
|
293
|
-
"name": "
|
|
444
|
+
"name": "openLinkModal",
|
|
294
445
|
"parameters": "",
|
|
295
446
|
"returnType": "any",
|
|
296
447
|
"description": ""
|
|
297
448
|
},
|
|
298
449
|
{
|
|
299
|
-
"name": "
|
|
300
|
-
"parameters": "
|
|
450
|
+
"name": "updater",
|
|
451
|
+
"parameters": "block);\n\n this.#saveHistory();\n this.#commitDocument(newDoc, { guardSelectionChange: true });\n\n // Synchronous — #commitDocument uses innerHTML so the new DOM is ready.\n const newImg = this.editorRef()?.nativeElement.querySelector(\n `img[data-block-index=\"${blockIndex}\"]`\n ) as HTMLImageElement;\n if (newImg",
|
|
301
452
|
"returnType": "any",
|
|
302
453
|
"description": ""
|
|
303
454
|
},
|
|
@@ -350,41 +501,29 @@
|
|
|
350
501
|
"description": ""
|
|
351
502
|
},
|
|
352
503
|
{
|
|
353
|
-
"name": "
|
|
354
|
-
"parameters": "
|
|
355
|
-
"returnType": "any",
|
|
356
|
-
"description": ""
|
|
357
|
-
},
|
|
358
|
-
{
|
|
359
|
-
"name": "onWindowResize",
|
|
360
|
-
"parameters": "",
|
|
504
|
+
"name": "setImageSize",
|
|
505
|
+
"parameters": "size: 'auto' | 'small' | 'medium' | 'large'",
|
|
361
506
|
"returnType": "any",
|
|
362
507
|
"description": ""
|
|
363
508
|
},
|
|
364
509
|
{
|
|
365
|
-
"name": "
|
|
366
|
-
"parameters": "",
|
|
510
|
+
"name": "onDrop",
|
|
511
|
+
"parameters": "event: DragEvent",
|
|
367
512
|
"returnType": "any",
|
|
368
513
|
"description": ""
|
|
369
514
|
},
|
|
370
515
|
{
|
|
371
|
-
"name": "
|
|
372
|
-
"parameters": "event:
|
|
516
|
+
"name": "onMouseDown",
|
|
517
|
+
"parameters": "event: MouseEvent",
|
|
373
518
|
"returnType": "any",
|
|
374
519
|
"description": ""
|
|
375
520
|
},
|
|
376
521
|
{
|
|
377
|
-
"name": "
|
|
522
|
+
"name": "onDragEnd",
|
|
378
523
|
"parameters": "event: DragEvent",
|
|
379
524
|
"returnType": "any",
|
|
380
525
|
"description": ""
|
|
381
526
|
},
|
|
382
|
-
{
|
|
383
|
-
"name": "onPaste",
|
|
384
|
-
"parameters": "event: ClipboardEvent",
|
|
385
|
-
"returnType": "any",
|
|
386
|
-
"description": ""
|
|
387
|
-
},
|
|
388
527
|
{
|
|
389
528
|
"name": "insertImage",
|
|
390
529
|
"parameters": "url: string",
|
|
@@ -428,15 +567,21 @@
|
|
|
428
567
|
"description": ""
|
|
429
568
|
},
|
|
430
569
|
{
|
|
431
|
-
"name": "
|
|
432
|
-
"parameters": "",
|
|
570
|
+
"name": "indent",
|
|
571
|
+
"parameters": "pos: LogicalPosition",
|
|
433
572
|
"returnType": "any",
|
|
434
573
|
"description": ""
|
|
435
574
|
},
|
|
436
575
|
{
|
|
437
|
-
"name": "
|
|
438
|
-
"parameters": "
|
|
439
|
-
"returnType": "
|
|
576
|
+
"name": "outdent",
|
|
577
|
+
"parameters": "pos: LogicalPosition",
|
|
578
|
+
"returnType": "any",
|
|
579
|
+
"description": ""
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
"name": "clear",
|
|
583
|
+
"parameters": "",
|
|
584
|
+
"returnType": "any",
|
|
440
585
|
"description": ""
|
|
441
586
|
}
|
|
442
587
|
],
|
|
@@ -492,13 +637,17 @@
|
|
|
492
637
|
{
|
|
493
638
|
"name": "--editor-shadow-focus",
|
|
494
639
|
"defaultValue": "0 0 0 3px var(--primary-3)"
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
"name": "--editor-shape",
|
|
643
|
+
"defaultValue": "var(--shape-3)"
|
|
495
644
|
}
|
|
496
645
|
],
|
|
497
646
|
"examples": [
|
|
498
647
|
{
|
|
499
648
|
"name": "sandbox-editor",
|
|
500
|
-
"html": "<div class=\"sandbox-container\">\n <div class=\"sandbox-sidebar\">\n <sh-card class=\"type-b control-card\">\n <h3>Options</h3>\n\n <div class=\"control-group\">\n <label class=\"control-label\">Storage Format</label>\n <sh-button-group size=\"small\" [value]=\"selectedFormat()\" (valueChange)=\"onFormatChange($event)\">\n <button value=\"html\">HTML</button>\n <button value=\"markdown\">Markdown</button>\n <button value=\"json\">JSON</button>\n </sh-button-group>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"readonly-toggle\" class=\"control-label\">Read-Only</label>\n <sh-toggle id=\"readonly-toggle\" [(checked)]=\"isReadonly\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"toolbar-toggle\" class=\"control-label\">Show Toolbar</label>\n <sh-toggle id=\"toolbar-toggle\" [(checked)]=\"showToolbar\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"image-upload-toggle\" class=\"control-label\">Image Upload</label>\n <sh-toggle id=\"image-upload-toggle\" [(checked)]=\"imageUploadEnabled\"></sh-toggle>\n </div>\n\n @if (showToolbar()) {\n <div class=\"control-group-section-title\">Toolbar Groups</div>\n\n <div class=\"control-group inline\">\n <label for=\"formats-toggle\" class=\"control-label\">Formats</label>\n <sh-toggle id=\"formats-toggle\" [(checked)]=\"showFormats\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"blocks-toggle\" class=\"control-label\">Blocks/Headings</label>\n <sh-toggle id=\"blocks-toggle\" [(checked)]=\"showBlocks\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"lists-toggle\" class=\"control-label\">Lists</label>\n <sh-toggle id=\"lists-toggle\" [(checked)]=\"showLists\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"align-toggle\" class=\"control-label\">Alignments</label>\n <sh-toggle id=\"align-toggle\" [(checked)]=\"showAlignments\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"insert-toggle\" class=\"control-label\">Insertions</label>\n <sh-toggle id=\"insert-toggle\" [(checked)]=\"showInsertions\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"history-toggle\" class=\"control-label\">History/Code</label>\n <sh-toggle id=\"history-toggle\" [(checked)]=\"showHistory\"></sh-toggle>\n </div>\n }\n </sh-card>\n </div>\n\n <div class=\"sandbox-editor-wrapper\">\n <sh-editor\n [(value)]=\"editorValue\"\n [format]=\"selectedFormat()\"\n [readonly]=\"isReadonly()\"\n [toolbar]=\"showToolbar()\"\n [showFormats]=\"showFormats()\"\n [showBlocks]=\"showBlocks()\"\n [showLists]=\"showLists()\"\n [showAlignments]=\"showAlignments()\"\n [showInsertions]=\"showInsertions()\"\n [showHistory]=\"showHistory()\"\n [customCommands]=\"customEditorCommands()\"\n [imageUploadEnabled]=\"imageUploadEnabled()\"\n placeholder=\"Start writing your premium content here...\">\n <!-- Projected custom toolbar action -->\n <button type=\"button\" class=\"sh-editor-btn\" (click)=\"customAction()\" shTooltip=\"Projected Action (Star)\">\n <sh-icon>star</sh-icon>\n </button
|
|
501
|
-
"ts": "import { ChangeDetectionStrategy, Component, computed, signal, viewChild } from '@angular/core';\nimport { ShipColor } from '@ship-ui/core';\nimport { ShipButton } from '@ship-ui/core/ship-button';\nimport { ShipButtonGroup } from '@ship-ui/core/ship-button-group';\nimport { ShipCard } from '@ship-ui/core/ship-card';\nimport { ShipEditor, ShipEditorCommand, ShipEditorValue } from '@ship-ui/core/ship-editor';\nimport { ShipIcon } from '@ship-ui/core/ship-icon';\nimport { ShipToggle } from '@ship-ui/core/ship-toggle';\n\n@Component({\n selector: 'app-sandbox-editor',\n standalone: true,\n imports: [ShipEditor, ShipButton, ShipCard, ShipToggle, ShipIcon, ShipButtonGroup],\n templateUrl: './sandbox-editor.html',\n styleUrl: './sandbox-editor.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SandboxEditor {\n // Current format selection\n selectedFormat = signal<'html' | 'markdown' | 'json'>('html');\n\n editor = viewChild(ShipEditor);\n\n // Editor states\n isReadonly = signal<boolean>(false);\n showToolbar = signal<boolean>(true);\n selectedColor = signal<ShipColor | null>(null);\n imageUploadEnabled = signal<boolean>(true);\n\n // Toolbar group configurations\n showFormats = signal<boolean>(true);\n showBlocks = signal<boolean>(true);\n showLists = signal<boolean>(true);\n showAlignments = signal<boolean>(true);\n showInsertions = signal<boolean>(true);\n showHistory = signal<boolean>(true);\n\n // Custom action for projected button\n customAction() {\n alert('Custom Projected Button Clicked!');\n }\n\n customEditorCommands = signal<ShipEditorCommand[]>([\n {\n
|
|
649
|
+
"html": "<div class=\"sandbox-container\">\n <div class=\"sandbox-sidebar\">\n <sh-card class=\"type-b control-card\">\n <h3>Options</h3>\n\n <div class=\"control-group\">\n <label class=\"control-label\">Storage Format</label>\n <sh-button-group size=\"small\" [value]=\"selectedFormat()\" (valueChange)=\"onFormatChange($event)\">\n <button value=\"html\">HTML</button>\n <button value=\"markdown\">Markdown</button>\n <button value=\"json\">JSON</button>\n </sh-button-group>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"readonly-toggle\" class=\"control-label\">Read-Only</label>\n <sh-toggle id=\"readonly-toggle\" [(checked)]=\"isReadonly\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"toolbar-toggle\" class=\"control-label\">Show Toolbar</label>\n <sh-toggle id=\"toolbar-toggle\" [(checked)]=\"showToolbar\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"image-upload-toggle\" class=\"control-label\">Image Upload</label>\n <sh-toggle id=\"image-upload-toggle\" [(checked)]=\"imageUploadEnabled\"></sh-toggle>\n </div>\n\n @if (showToolbar()) {\n <div class=\"control-group-section-title\">Toolbar Groups</div>\n\n <div class=\"control-group inline\">\n <label for=\"formats-toggle\" class=\"control-label\">Formats</label>\n <sh-toggle id=\"formats-toggle\" [(checked)]=\"showFormats\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"blocks-toggle\" class=\"control-label\">Blocks/Headings</label>\n <sh-toggle id=\"blocks-toggle\" [(checked)]=\"showBlocks\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"lists-toggle\" class=\"control-label\">Lists</label>\n <sh-toggle id=\"lists-toggle\" [(checked)]=\"showLists\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"align-toggle\" class=\"control-label\">Alignments</label>\n <sh-toggle id=\"align-toggle\" [(checked)]=\"showAlignments\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"insert-toggle\" class=\"control-label\">Insertions</label>\n <sh-toggle id=\"insert-toggle\" [(checked)]=\"showInsertions\"></sh-toggle>\n </div>\n\n <div class=\"control-group inline\">\n <label for=\"history-toggle\" class=\"control-label\">History/Code</label>\n <sh-toggle id=\"history-toggle\" [(checked)]=\"showHistory\"></sh-toggle>\n </div>\n }\n </sh-card>\n </div>\n\n <div class=\"sandbox-editor-wrapper\">\n <sh-editor\n [(value)]=\"editorValue\"\n [format]=\"selectedFormat()\"\n [readonly]=\"isReadonly()\"\n [toolbar]=\"showToolbar()\"\n [showFormats]=\"showFormats()\"\n [showBlocks]=\"showBlocks()\"\n [showLists]=\"showLists()\"\n [showAlignments]=\"showAlignments()\"\n [showInsertions]=\"showInsertions()\"\n [showHistory]=\"showHistory()\"\n [customCommands]=\"customEditorCommands()\"\n [imageUploadEnabled]=\"imageUploadEnabled()\"\n placeholder=\"Start writing your premium content here...\">\n <!-- Projected custom toolbar action -->\n <!-- <button type=\"button\" class=\"sh-editor-btn\" (click)=\"customAction()\" shTooltip=\"Projected Action (Star)\">\n <sh-icon>star</sh-icon>\n </button> -->\n </sh-editor>\n\n <sh-card class=\"type-b output-card\">\n <div class=\"output-header\">\n <h3>Database Output</h3>\n <button shButton variant=\"outlined\" size=\"small\" (click)=\"copyToClipboard()\">\n <sh-icon>copy</sh-icon>\n Copy\n </button>\n </div>\n <p class=\"output-desc\">This is exactly what would be saved in your database:</p>\n <pre class=\"serialized-preview\"><code>{{ serializedOutput() }}</code></pre>\n </sh-card>\n </div>\n</div>\n",
|
|
650
|
+
"ts": "import { ChangeDetectionStrategy, Component, computed, signal, viewChild } from '@angular/core';\nimport { ShipColor } from '@ship-ui/core';\nimport { ShipButton } from '@ship-ui/core/ship-button';\nimport { ShipButtonGroup } from '@ship-ui/core/ship-button-group';\nimport { ShipCard } from '@ship-ui/core/ship-card';\nimport { ShipEditor, ShipEditorCommand, ShipEditorRegistry, ShipEditorValue } from '@ship-ui/core/ship-editor';\nimport { ShipIcon } from '@ship-ui/core/ship-icon';\nimport { ShipToggle } from '@ship-ui/core/ship-toggle';\n\n@Component({\n selector: 'app-sandbox-editor',\n standalone: true,\n imports: [ShipEditor, ShipButton, ShipCard, ShipToggle, ShipIcon, ShipButtonGroup],\n templateUrl: './sandbox-editor.html',\n styleUrl: './sandbox-editor.scss',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SandboxEditor {\n constructor() {\n // Register custom highlight mark extension\n ShipEditorRegistry.getInstance().registerMark({\n type: 'highlight',\n tagName: 'mark',\n toHTML: (mark: any, text: string) =>\n `<mark style=\"background-color: var(--warn-2); color: var(--warn-11); padding: 2px 4px; border-radius: 2px;\">${text}</mark>`,\n parseHTML: (el: HTMLElement) => (el.tagName.toLowerCase() === 'mark' ? { type: 'highlight' } : null),\n });\n }\n\n // Current format selection\n selectedFormat = signal<'html' | 'markdown' | 'json'>('html');\n\n editor = viewChild(ShipEditor);\n\n // Editor states\n isReadonly = signal<boolean>(false);\n showToolbar = signal<boolean>(true);\n selectedColor = signal<ShipColor | null>(null);\n imageUploadEnabled = signal<boolean>(true);\n\n // Toolbar group configurations\n showFormats = signal<boolean>(true);\n showBlocks = signal<boolean>(true);\n showLists = signal<boolean>(true);\n showAlignments = signal<boolean>(true);\n showInsertions = signal<boolean>(true);\n showHistory = signal<boolean>(true);\n\n // Custom action for projected button\n customAction() {\n alert('Custom Projected Button Clicked!');\n }\n\n customEditorCommands = signal<ShipEditorCommand[]>([\n // {\n // id: 'highlight',\n // label: 'Highlight Text',\n // icon: 'star',\n // description: 'Highlight selected text in yellow',\n // action: (editor) => {\n // editor.runTransaction((doc, selection) => {\n // return formatDocRange(doc, selection.start, selection.end, 'highlight', 'toggle');\n // });\n // },\n // },\n // {\n // id: 'info-callout',\n // label: 'Info Callout',\n // icon: 'terminal',\n // description: 'Insert an info callout box',\n // action: (editor) => {\n // editor.runTransaction((doc, selection) => {\n // const { doc: splitDoc } = splitBlock(doc, selection.start);\n // const calloutBlock: ShipEditorBlock = {\n // type: 'quote',\n // attrs: { align: 'left' },\n // content: [{ type: 'text', text: '💡 Info: Start typing callout contents here...' }],\n // };\n // const targetIndex = selection.start.blockIndex + 1;\n // splitDoc.splice(targetIndex, 0, calloutBlock);\n // return {\n // doc: splitDoc,\n // selectionShift: {\n // start: { blockIndex: targetIndex },\n // end: { blockIndex: targetIndex },\n // },\n // };\n // });\n // },\n // },\n ]);\n\n // Pre-populated initial contents for each format\n initialHtml = `<h1>Ship WYSIWYG Editor</h1><p>Welcome! This is a <strong>config-driven</strong> rich-text editor designed to support flexible storage formats.</p><ul><li><strong>Two-way binding</strong> with <code>ControlValueAccessor</code></li><li>Instant conversion to <strong>HTML</strong>, <strong>Markdown</strong>, or <strong>JSON</strong></li><li>Sticky blur-toolbar, light/dark mode support, and word counting</li></ul><blockquote>\"A beautiful interface makes editing content a delight.\"</blockquote><hr><p>Try changing the storage format below to see the serialized output update in real time!</p>`;\n\n // Current value model\n editorValue = signal<ShipEditorValue>(this.initialHtml);\n\n // Stringified preview for database presentation\n serializedOutput = computed(() => {\n const val = this.editorValue();\n if (val === null || val === undefined) return '';\n if (typeof val === 'string') return val;\n return JSON.stringify(val, null, 2);\n });\n\n // Handle format swapping, converting the value appropriately\n onFormatChange(format: string | null) {\n if (format === 'html' || format === 'markdown' || format === 'json') {\n this.selectedFormat.set(format);\n }\n }\n\n // Copy serialized contents to clipboard\n copyToClipboard() {\n navigator.clipboard.writeText(this.serializedOutput());\n }\n}\n"
|
|
502
651
|
}
|
|
503
652
|
]
|
|
504
653
|
},
|
|
@@ -3401,6 +3550,12 @@
|
|
|
3401
3550
|
"description": "",
|
|
3402
3551
|
"defaultValue": "false"
|
|
3403
3552
|
},
|
|
3553
|
+
{
|
|
3554
|
+
"name": "ctrlOrCmd",
|
|
3555
|
+
"type": "boolean, string | boolean",
|
|
3556
|
+
"description": "",
|
|
3557
|
+
"defaultValue": "false"
|
|
3558
|
+
},
|
|
3404
3559
|
{
|
|
3405
3560
|
"name": "enter",
|
|
3406
3561
|
"type": "boolean, string | boolean",
|
|
@@ -4564,8 +4719,8 @@
|
|
|
4564
4719
|
},
|
|
4565
4720
|
{
|
|
4566
4721
|
"name": "toggle-card-disallowed",
|
|
4567
|
-
"html": "<sh-toggle-card [disableToggle]=\"true\">\n <ng-container title>Advanced options</ng-container>\n <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>\n</sh-toggle-card>\n",
|
|
4568
|
-
"ts": "import { ChangeDetectionStrategy, Component } from '@angular/core';\nimport { ShipToggleCard } from '@ship-ui/core/ship-toggle-card';\n\n@Component({\n selector: 'app-toggle-card-disallowed-example',\n standalone: true,\n imports: [ShipToggleCard],\n templateUrl: './toggle-card-disallowed.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class ToggleCardDisallowedExampleComponent {}\n"
|
|
4722
|
+
"html": "<sh-toggle-card [disableToggle]=\"true\">\n <ng-container title>Advanced options</ng-container>\n <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>\n</sh-toggle-card>\n\n<sh-toggle-card [disableToggle]=\"true\">\n <div title style=\"display: flex; align-items: center; justify-content: space-between; width: 100%;\">\n <span>Options</span>\n <sh-menu [searchable]=\"true\">\n <button shButton class=\"outlined small\">Select option</button>\n <ng-container menu>\n @for (item of menuItems; track item.value) {\n <button (click)=\"select(item)\" [class.selected]=\"selected === item.value\">\n {{ item.label }}\n </button>\n }\n </ng-container>\n </sh-menu>\n </div>\n <p>This toggle card has a searchable menu in its title. You should be able to type in the search field.</p>\n</sh-toggle-card>\n",
|
|
4723
|
+
"ts": "import { ChangeDetectionStrategy, Component } from '@angular/core';\nimport { ShipButton } from '@ship-ui/core/ship-button';\nimport { ShipMenu } from '@ship-ui/core/ship-menu';\nimport { ShipToggleCard } from '@ship-ui/core/ship-toggle-card';\n\n@Component({\n selector: 'app-toggle-card-disallowed-example',\n standalone: true,\n imports: [ShipToggleCard, ShipMenu, ShipButton],\n templateUrl: './toggle-card-disallowed.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class ToggleCardDisallowedExampleComponent {\n menuItems = [\n { label: 'Dashboard', value: 'dashboard' },\n { label: 'Users', value: 'users' },\n { label: 'Settings', value: 'settings' },\n { label: 'Billing', value: 'billing' },\n ];\n selected: string | null = null;\n\n select(item: any) {\n this.selected = item.value;\n }\n}\n"
|
|
4569
4724
|
},
|
|
4570
4725
|
{
|
|
4571
4726
|
"name": "base-card",
|
|
@@ -5081,6 +5236,18 @@
|
|
|
5081
5236
|
"inputs": [],
|
|
5082
5237
|
"outputs": [],
|
|
5083
5238
|
"methods": [
|
|
5239
|
+
{
|
|
5240
|
+
"name": "pause",
|
|
5241
|
+
"parameters": "",
|
|
5242
|
+
"returnType": "void",
|
|
5243
|
+
"description": ""
|
|
5244
|
+
},
|
|
5245
|
+
{
|
|
5246
|
+
"name": "resume",
|
|
5247
|
+
"parameters": "",
|
|
5248
|
+
"returnType": "void",
|
|
5249
|
+
"description": ""
|
|
5250
|
+
},
|
|
5084
5251
|
{
|
|
5085
5252
|
"name": "registerDefaults",
|
|
5086
5253
|
"parameters": "defaults: Record<string, string>",
|
|
@@ -5839,7 +6006,7 @@
|
|
|
5839
6006
|
"methods": [
|
|
5840
6007
|
{
|
|
5841
6008
|
"name": "onCleanup",
|
|
5842
|
-
"parameters": "() => {\n
|
|
6009
|
+
"parameters": "() => {\n if (this.searchable()",
|
|
5843
6010
|
"returnType": "any",
|
|
5844
6011
|
"description": ""
|
|
5845
6012
|
},
|
|
@@ -5881,6 +6048,11 @@
|
|
|
5881
6048
|
}
|
|
5882
6049
|
],
|
|
5883
6050
|
"examples": [
|
|
6051
|
+
{
|
|
6052
|
+
"name": "titles-search-menu-example",
|
|
6053
|
+
"html": "<sh-menu [searchable]=\"true\">\n <button shButton class=\"outlined\">Menu with Titles and Search</button>\n <ng-container menu>\n @for (section of sections; track section.title) {\n <h3>{{ section.title }}</h3>\n @for (item of section.items; track item.value) {\n <button (click)=\"select(item.value)\" [class.selected]=\"selected === item.value\">\n {{ item.label }}\n </button>\n }\n }\n </ng-container>\n</sh-menu>\n",
|
|
6054
|
+
"ts": "import { Component } from '@angular/core';\nimport { ShipButton } from '@ship-ui/core/ship-button';\nimport { ShipMenu } from '@ship-ui/core/ship-menu';\n\n@Component({\n selector: 'sh-titles-search-menu-example',\n templateUrl: './titles-search-menu-example.html',\n styleUrls: ['./titles-search-menu-example.scss'],\n imports: [ShipMenu, ShipButton],\n standalone: true,\n})\nexport class TitlesSearchMenuExample {\n sections = [\n {\n title: 'Admin',\n items: [\n { label: 'Dashboard', value: 'dashboard' },\n { label: 'Users', value: 'users' },\n { label: 'Permissions', value: 'permissions' },\n ]\n },\n {\n title: 'Personal',\n items: [\n { label: 'Profile', value: 'profile' },\n { label: 'Settings', value: 'settings' },\n { label: 'Security', value: 'security' },\n ]\n },\n {\n title: 'Support',\n items: [\n { label: 'Help Center', value: 'help' },\n { label: 'Feedback', value: 'feedback' },\n { label: 'Contact Us', value: 'contact' },\n ]\n }\n ];\n selected: string | null = null;\n\n select(value: string) {\n this.selected = value;\n }\n}\n"
|
|
6055
|
+
},
|
|
5884
6056
|
{
|
|
5885
6057
|
"name": "icon-suffix-menu",
|
|
5886
6058
|
"html": "<sh-menu>\n <button shButton class=\"outlined\">Open menu</button>\n <ng-container menu>\n @for (item of menuItems; track item.value) {\n <button (click)=\"select(item)\" [class.selected]=\"selected() === item.value\">\n <sh-icon>circle</sh-icon>\n {{ item.label }}\n <span suffix>{{ item.hotkey }}</span>\n </button>\n }\n </ng-container>\n</sh-menu>\n",
|