@fiduswriter/editor 0.1.66 → 0.1.68

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.
@@ -0,0 +1,360 @@
1
+ import type {CSL, EditorImageDB} from "./types.js"
2
+ import {BibliographyDB} from "@fiduswriter/bibliography-manager/database"
3
+ import type {BibliographyApi} from "@fiduswriter/bibliography-manager"
4
+ import {ImageDB} from "@fiduswriter/image-manager/database"
5
+ import type {ImageApi} from "@fiduswriter/image-manager"
6
+ import type {Image, SaveImageResponse} from "@fiduswriter/image-manager/types"
7
+ import {FW_DOCUMENT_VERSION} from "@fiduswriter/document/schema"
8
+ import {extractTemplate} from "@fiduswriter/document/importer/native/extract_template"
9
+
10
+ import type {
11
+ EditorApp,
12
+ EditorContactsApi,
13
+ EditorDocumentApi,
14
+ EditorDocumentImportApi
15
+ } from "./types.js"
16
+
17
+ export interface StaticDocumentStyle {
18
+ title: string
19
+ slug: string
20
+ contents: string
21
+ documentstylefile_set: Array<[string, string]>
22
+ }
23
+
24
+ export interface StaticExportTemplate {
25
+ title: string
26
+ file_type: string
27
+ template_file: string
28
+ }
29
+
30
+ export interface StaticDocumentTemplate {
31
+ title: string
32
+ }
33
+
34
+ export interface StaticAppConfig {
35
+ /** Locale code used by the editor, e.g. "en". */
36
+ locale: string
37
+ /** gettext function for UI strings. */
38
+ gettext: (msgid: string) => string
39
+ /** CSL engine instance. */
40
+ csl: CSL
41
+ /**
42
+ * Function returning the document payload the editor should load.
43
+ * Called whenever the editor refreshes document data from the server.
44
+ */
45
+ documentData: () => Promise<{
46
+ doc: Record<string, unknown>
47
+ doc_info: Record<string, unknown>
48
+ time: number
49
+ }>
50
+ /**
51
+ * Optional callback returning the current document content node.
52
+ * Used by File > Download to extract a template definition from the document.
53
+ */
54
+ getDocContent?: () => Record<string, unknown> | undefined
55
+ /** Initial image entries keyed by image id. */
56
+ initialImages?: Record<number, Image>
57
+ /** Document styles available for the document template. */
58
+ documentStyles?: StaticDocumentStyle[]
59
+ /** Export templates available for the document template. */
60
+ exportTemplates?: StaticExportTemplate[]
61
+ /** Document templates keyed by import id. */
62
+ documentTemplates?: Record<string, StaticDocumentTemplate>
63
+ /** Optional override for the template API response. */
64
+ getTemplateForDoc?: (
65
+ docId: string | number,
66
+ token: string | false
67
+ ) => Promise<Record<string, unknown>>
68
+ /** Application name. */
69
+ appName?: string
70
+ /** Routes table used by the app router. */
71
+ routes?: Record<string, {app: string}>
72
+ /**
73
+ * Optional handler called when the editor tries to save the document.
74
+ * Defaults to a no-op that returns version 0.
75
+ */
76
+ onSaveDocument?: (data: Record<string, unknown>) => Promise<{
77
+ json: Record<string, unknown>
78
+ status: number
79
+ }>
80
+ }
81
+
82
+ interface StoredImage {
83
+ id: number
84
+ title: string
85
+ file_type: string
86
+ image: string
87
+ thumbnail?: string
88
+ width: number
89
+ height: number
90
+ added: number
91
+ cats: number[]
92
+ copyright: Image["copyright"]
93
+ [key: string]: unknown
94
+ }
95
+
96
+ function fileToDataUrl(file: Blob): Promise<string> {
97
+ return new Promise((resolve, reject) => {
98
+ const reader = new FileReader()
99
+ reader.onload = () => resolve(String(reader.result))
100
+ reader.onerror = reject
101
+ reader.readAsDataURL(file)
102
+ })
103
+ }
104
+
105
+ function getImageDimensions(
106
+ dataUrl: string
107
+ ): Promise<{width: number; height: number}> {
108
+ return new Promise((resolve, reject) => {
109
+ const img = new Image()
110
+ img.onload = () => resolve({width: img.width, height: img.height})
111
+ img.onerror = reject
112
+ img.src = dataUrl
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Create an {@link EditorApp} for a statically served Fidus Writer editor.
118
+ *
119
+ * This wires up in-memory API connectors so that the editor can run without a
120
+ * backend server. It is used by the standalone demo and can be used by any
121
+ * static deployment of `@fiduswriter/editor`.
122
+ */
123
+ export async function createStaticApp(
124
+ config: StaticAppConfig
125
+ ): Promise<EditorApp> {
126
+ const documentStyles = config.documentStyles || []
127
+ const exportTemplates = config.exportTemplates || []
128
+ const documentTemplates = config.documentTemplates || {}
129
+
130
+ // In-memory store for images uploaded during this session.
131
+ const sessionImages: Record<number, StoredImage> = {}
132
+ let nextImageId = 1
133
+
134
+ if (config.initialImages) {
135
+ Object.entries(config.initialImages).forEach(([id, image]) => {
136
+ sessionImages[Number(id)] = image as StoredImage
137
+ })
138
+ }
139
+
140
+ async function storeImage(
141
+ data: Record<string, unknown>,
142
+ files: Record<string, unknown>
143
+ ): Promise<StoredImage> {
144
+ const id = data.id ? Number(data.id) : nextImageId++
145
+ const existing = sessionImages[id]
146
+ const file =
147
+ (files?.image as {file?: Blob})?.file ??
148
+ (files?.image as Blob) ??
149
+ (data.image as Blob)
150
+
151
+ let imageUrl = existing?.image ?? ""
152
+ let fileType = existing?.file_type ?? "png"
153
+ let width = existing?.width ?? 0
154
+ let height = existing?.height ?? 0
155
+
156
+ if (file) {
157
+ imageUrl = await fileToDataUrl(file)
158
+ fileType =
159
+ (data.original_file_type as string) ||
160
+ (file as File).type ||
161
+ "image/png"
162
+ const dimensions = await getImageDimensions(imageUrl)
163
+ width = dimensions.width
164
+ height = dimensions.height
165
+ }
166
+
167
+ const image: StoredImage = {
168
+ id,
169
+ title: (data.title as string) || existing?.title || "",
170
+ file_type: fileType,
171
+ image: imageUrl,
172
+ width,
173
+ height,
174
+ added: existing?.added || Date.now(),
175
+ cats: (data.cats as number[]) || existing?.cats || [],
176
+ copyright:
177
+ (data.copyright as Image["copyright"]) ||
178
+ existing?.copyright || {freeToRead: true, licenses: []}
179
+ }
180
+ sessionImages[id] = image
181
+ return image
182
+ }
183
+
184
+ const defaultGetTemplateForDoc = async () => {
185
+ const docContent = config.getDocContent?.() as Record<string, unknown> | undefined
186
+ const template = docContent
187
+ ? extractTemplate(docContent as unknown as any)
188
+ : null
189
+ const title =
190
+ ((docContent as any)?.attrs?.template as string) ||
191
+ ((template as any)?.content?.attrs?.template as string) ||
192
+ ""
193
+ return {
194
+ json: {
195
+ id: 1,
196
+ title,
197
+ content: template?.content ?? {},
198
+ doc_version: FW_DOCUMENT_VERSION,
199
+ export_templates: exportTemplates.map(template => ({
200
+ fields: {
201
+ template_file: template.template_file,
202
+ file_type: template.file_type,
203
+ title: template.title
204
+ }
205
+ })),
206
+ document_styles: documentStyles.map(style => ({
207
+ fields: {
208
+ contents: style.contents,
209
+ slug: style.slug,
210
+ title: style.title,
211
+ documentstylefile_set: style.documentstylefile_set
212
+ }
213
+ }))
214
+ },
215
+ status: 200
216
+ }
217
+ }
218
+
219
+ const documentApi: EditorDocumentApi = {
220
+ createDocument: async () => ({json: {id: 1}, status: 200}),
221
+ getWebSocketBase: async () => ({json: {ws_base: ""}, status: 200}),
222
+ getDocumentStyles: async () => ({
223
+ json: {
224
+ export_templates: exportTemplates,
225
+ document_styles: documentStyles,
226
+ document_templates: documentTemplates
227
+ },
228
+ status: 200
229
+ }),
230
+ getDocumentData: async () => {
231
+ const data = await config.documentData()
232
+ return {json: data, status: 200}
233
+ },
234
+ saveDocument: async data => {
235
+ if (config.onSaveDocument) {
236
+ return config.onSaveDocument(data)
237
+ }
238
+ return {json: {version: 0}, status: 200}
239
+ },
240
+ commentNotify: async () => Promise.resolve(),
241
+ requestAccess: async () => ({json: {}, status: 200}),
242
+ validateShareToken: async () => ({json: {}, status: 404}),
243
+ listShareTokens: async () => ({json: [], status: 200}),
244
+ createShareToken: async () => ({json: {}, status: 200}),
245
+ revokeShareToken: async () => ({json: {}, status: 200}),
246
+ getAccessRights: async () => ({json: {}, status: 200}),
247
+ saveAccessRights: async () => Promise.resolve(),
248
+ saveE2EEImage: async () => ({json: {}, status: 200}),
249
+ deleteE2EEImage: async () => Promise.resolve(),
250
+ uploadRevision: async () => Promise.resolve(),
251
+ getTemplateForDoc: async (id, token) => {
252
+ if (config.getTemplateForDoc) {
253
+ return {
254
+ json: await config.getTemplateForDoc(id, token),
255
+ status: 200
256
+ }
257
+ }
258
+ return defaultGetTemplateForDoc()
259
+ }
260
+ }
261
+
262
+ const documentImportApi: EditorDocumentImportApi = {
263
+ createDoc: async () => ({json: {id: 1}, status: 200}),
264
+ saveImage: async (data, files) => {
265
+ const image = await storeImage(
266
+ data as Record<string, unknown>,
267
+ (files as Record<string, unknown>) || {}
268
+ )
269
+ return {json: {id: image.id}, status: 200}
270
+ },
271
+ saveE2EEImage: async () => ({json: {}, status: 200}),
272
+ saveDocument: async data => {
273
+ if (config.onSaveDocument) {
274
+ return config.onSaveDocument(data)
275
+ }
276
+ return {json: {version: 0}, status: 200}
277
+ }
278
+ }
279
+
280
+ const imageApi: ImageApi = {
281
+ getImages: async () => ({
282
+ imageCategories: [],
283
+ images: Object.values(sessionImages) as Image[]
284
+ }),
285
+ saveImage: async (data, files = {}) => {
286
+ const image = await storeImage(
287
+ data as Record<string, unknown>,
288
+ files as Record<string, unknown>
289
+ )
290
+ return {
291
+ errormsg: {},
292
+ values: image
293
+ } as SaveImageResponse
294
+ },
295
+ saveCategories: async () => ({entries: []}),
296
+ deleteImages: async ids => {
297
+ ids.forEach(id => delete sessionImages[id])
298
+ return Promise.resolve()
299
+ }
300
+ }
301
+
302
+ const bibliographyApi: BibliographyApi = {
303
+ getDB: async () => ({
304
+ bib_categories: [],
305
+ bib_list: [],
306
+ last_modified: -1,
307
+ number_of_entries: 0,
308
+ user_id: 1
309
+ }),
310
+ saveBibEntries: async tmpDB => ({
311
+ id_translations: Object.keys(tmpDB).map(tmpId => [
312
+ Number.parseInt(tmpId),
313
+ Number.parseInt(tmpId)
314
+ ])
315
+ }),
316
+ saveCategories: async () => ({entries: []}),
317
+ deleteCategory: async () =>
318
+ new Response(JSON.stringify({}), {status: 200}),
319
+ deleteBibEntries: async () =>
320
+ new Response(JSON.stringify({}), {status: 200})
321
+ }
322
+
323
+ const contactsApi: EditorContactsApi = {
324
+ add: async () => ({json: {}, status: 200})
325
+ }
326
+
327
+ const app = {
328
+ name: config.appName || "fiduswriter-static-editor",
329
+ routes: config.routes || {
330
+ "": {app: "document"},
331
+ document: {app: "document"}
332
+ },
333
+ goTo: () => {},
334
+ isOffline: () => false,
335
+ settings: {
336
+ APPS: [config.appName || "static"],
337
+ EDITOR_SAVE_MODE: "external",
338
+ EDITOR_ONLY_MODE: true,
339
+ E2EE_MODE: "disabled",
340
+ LANGUAGE: config.locale
341
+ },
342
+ csl: config.csl,
343
+ apiConnectors: {
344
+ document: documentApi,
345
+ documentImport: documentImportApi,
346
+ image: imageApi,
347
+ bibliography: bibliographyApi,
348
+ contacts: contactsApi
349
+ }
350
+ } as unknown as EditorApp
351
+
352
+ const bibDB = new BibliographyDB(app as any)
353
+ const imageDB = new ImageDB(app as any) as unknown as EditorImageDB
354
+ imageDB.setImage = (_id: number, _data: Record<string, unknown>) => {}
355
+
356
+ ;(app as any).bibDB = bibDB
357
+ ;(app as any).imageDB = imageDB
358
+
359
+ return app
360
+ }
@@ -0,0 +1,186 @@
1
+ import {gettext, initSettings, interpolate, staticUrl} from "fwtoolkit"
2
+
3
+ import type {CSL, EditorUser} from "./types.js"
4
+ import type {StaticAppConfig} from "./static_app.js"
5
+
6
+ import type {Editor} from "./index.js"
7
+
8
+ export type {StaticAppConfig} from "./static_app.js"
9
+
10
+ export interface StaticEditorConfig
11
+ extends Omit<StaticAppConfig, "gettext" | "csl"> {
12
+ /** gettext function for UI strings. When omitted, a function backed by `localeCatalog` is used. */
13
+ gettext?: (msgid: string) => string
14
+ /** CSL engine instance. When omitted, a default engine is created. */
15
+ csl?: CSL
16
+ /** Display name for the user. Used to build the user object when `user` is not given. */
17
+ username?: string
18
+ /** Pre-built user object. Takes precedence over `username`. */
19
+ user?: EditorUser
20
+ /**
21
+ * Base path for resolving static assets.
22
+ * Defaults to the directory containing the current page.
23
+ */
24
+ staticBasePath?: string
25
+ /**
26
+ * Optional locale catalog. When omitted, the catalog is fetched from
27
+ * `../locale/{locale}/messages.json` relative to the current page.
28
+ */
29
+ localeCatalog?: Record<string, string>
30
+ /**
31
+ * Optional callback for save attempts. Receives the document payload.
32
+ */
33
+ onSaveDocument?: (data: Record<string, unknown>) => Promise<{
34
+ json: Record<string, unknown>
35
+ status: number
36
+ }>
37
+ /** Optional extra editor plugins. */
38
+ plugins?: Array<[string, Record<string, unknown>]>
39
+ }
40
+
41
+ async function loadLocaleCatalog(
42
+ locale: string
43
+ ): Promise<Record<string, string>> {
44
+ try {
45
+ const response = await fetch(`../locale/${locale}/messages.json`)
46
+ if (!response.ok) {
47
+ return {}
48
+ }
49
+ return (await response.json()) as Record<string, string>
50
+ } catch {
51
+ return {}
52
+ }
53
+ }
54
+
55
+ function createGettext(catalog: Record<string, string>) {
56
+ return function gettextImpl(msgid: string): string {
57
+ return catalog[msgid] || msgid
58
+ }
59
+ }
60
+
61
+ function defaultStaticUrl(basePath: string): (path: string) => string {
62
+ return (path: string) => {
63
+ if (path.startsWith("css/editor/")) {
64
+ return `${basePath}css/${path.slice("css/editor/".length)}`
65
+ }
66
+ if (path === "css/bibliography/bibliography.css") {
67
+ return `${basePath}css/bibliography.css`
68
+ }
69
+ if (path.startsWith("css/")) {
70
+ return `${basePath}${path}`
71
+ }
72
+ return `${basePath}static/${path}`
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create and initialize a statically served Fidus Writer editor.
78
+ *
79
+ * This is the high-level entry point for running `@fiduswriter/editor` without
80
+ * a backend server. It sets up the runtime globals, loads locale strings,
81
+ * creates the static app shell, and initializes the editor.
82
+ *
83
+ * The lower-level {@link Editor} constructor and {@link createStaticApp} are
84
+ * still available for server-backed deployments or advanced customization.
85
+ */
86
+ export async function createStaticEditor(
87
+ config: StaticEditorConfig
88
+ ): Promise<Editor> {
89
+ // The editor source expects these Fidus Writer runtime helpers as globals.
90
+ // They must be present before any editor module is evaluated, because some
91
+ // editor modules call them at the top level.
92
+ ;(window as any).gettext = gettext
93
+ ;(window as any).interpolate = interpolate
94
+ ;(window as any).staticUrl = staticUrl
95
+
96
+ const locale = config.locale || "en"
97
+ const catalog =
98
+ config.localeCatalog ?? (await loadLocaleCatalog(locale))
99
+ const localeGettext = config.gettext ?? createGettext(catalog)
100
+
101
+ const basePath =
102
+ config.staticBasePath ??
103
+ window.location.pathname.replace(
104
+ /\/(?:editor\/(?:index\.html)?|index\.html)$/,
105
+ "/"
106
+ )
107
+
108
+ initSettings({
109
+ apiUrl: url => url,
110
+ apiUrlMap: {},
111
+ getCsrfToken: () => "",
112
+ gettext: localeGettext,
113
+ interpolate: (fmt, args, named) => {
114
+ if (named) {
115
+ return fmt.replace(/%\(([^)]+)\)s?/g, (_match, key) => {
116
+ const value = (args as unknown as Record<string, unknown>)[key]
117
+ return value !== undefined ? String(value) : ""
118
+ })
119
+ }
120
+ let index = 0
121
+ return fmt.replace(/%s/g, () => {
122
+ const value = (args as unknown[])[index++]
123
+ return value !== undefined ? String(value) : ""
124
+ })
125
+ },
126
+ staticUrl: defaultStaticUrl(basePath)
127
+ })
128
+
129
+ const username = config.username || "User"
130
+ const user: EditorUser =
131
+ config.user ??
132
+ ({
133
+ id: 1,
134
+ username: username.toLowerCase().replace(/\s+/g, "_") || "user",
135
+ emails: [{address: "user@example.com", primary: true}],
136
+ name: username,
137
+ is_authenticated: true
138
+ } as EditorUser)
139
+
140
+ let csl = config.csl
141
+ if (!csl) {
142
+ const {createCSL} = await import(
143
+ "@fiduswriter/document/citations/create_csl"
144
+ )
145
+ csl = await createCSL()
146
+ // createCSL replaces getStyle/getLocale with versions that only look at
147
+ // pre-registered styles. Restore the prototype methods so the bundled
148
+ // style/locale chunks are loaded dynamically.
149
+ const cslProto = Object.getPrototypeOf(csl)
150
+ ;(csl as any).getStyle = cslProto.getStyle
151
+ ;(csl as any).getLocale = cslProto.getLocale
152
+ }
153
+
154
+ const {createStaticApp} = await import("./static_app.js")
155
+
156
+ const app = await createStaticApp({
157
+ ...config,
158
+ locale,
159
+ gettext: localeGettext,
160
+ csl,
161
+ onSaveDocument: config.onSaveDocument
162
+ })
163
+
164
+ // Prime the user bibliography and image databases so dialogs that read
165
+ // from them see empty but valid stores.
166
+ await Promise.all([
167
+ (app.bibDB as any).getDB(),
168
+ (app.imageDB as any).getDB()
169
+ ])
170
+
171
+ const docInfo = (await config.documentData()).doc_info
172
+ const docId = (docInfo?.id as string | number) ?? 1
173
+ const docPath = (docInfo?.path as string) ?? ""
174
+
175
+ const {Editor} = await import("./index.js")
176
+
177
+ const editor = new Editor(
178
+ {app, user},
179
+ docPath,
180
+ String(docId),
181
+ config.plugins ?? ([] as Array<[string, Record<string, unknown>]>)
182
+ )
183
+
184
+ await editor.init()
185
+ return editor
186
+ }