fwtoolkit 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "fwtoolkit",
3
- "version": "0.1.0-alpha.1",
4
- "description": "Fidus Writer Toolkit — reusable utilities for Fidus Writer and other applications",
3
+ "version": "0.1.0-alpha.2",
4
+ "description": "Fidus Writer Toolkit — reusable utilities and UI helpers for Fidus Writer and other applications",
5
5
  "type": "module",
6
6
  "license": "LGPL-3.0-or-later",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://codeberg.org/fiduswriter/fwtoolkit.git"
10
+ },
7
11
  "author": "Johannes Wilm",
8
12
  "files": [
9
13
  "src/",
@@ -13,5 +17,9 @@
13
17
  "exports": {
14
18
  ".": "./src/index.js",
15
19
  "./*": "./src/*"
20
+ },
21
+ "dependencies": {
22
+ "diff-dom": "^5.2.1",
23
+ "w3c-keyname": "^2.2.8"
16
24
  }
17
25
  }
package/src/basic.js ADDED
@@ -0,0 +1,500 @@
1
+ import {keyName} from "w3c-keyname"
2
+
3
+ import {ContentMenu} from "./content_menu"
4
+ import {Dialog} from "./dialog"
5
+ import {isActivationEvent} from "./events"
6
+
7
+ /** Creates a styled select with a contentmenu from a select tag.
8
+ * @param select The select-tag which is to be replaced.
9
+ * @param options
10
+ */
11
+
12
+ export const dropdownSelect = (
13
+ selectDOM,
14
+ {onChange = _newValue => {}, width = false, value = false, button = false}
15
+ ) => {
16
+ if (!selectDOM.parentElement) {
17
+ return
18
+ }
19
+ let buttonDOM
20
+ if (button) {
21
+ buttonDOM = button
22
+ selectDOM.parentElement.removeChild(selectDOM) // Remove the <select> from the main dom.
23
+ } else {
24
+ buttonDOM = document.createElement("div")
25
+ buttonDOM.innerHTML =
26
+ '<label></label>&nbsp;<span class="fa fa-caret-down"></span>'
27
+ buttonDOM.classList.add(
28
+ "fw-button",
29
+ "fw-light",
30
+ "fw-large",
31
+ "fw-dropdown"
32
+ )
33
+ if (width) {
34
+ buttonDOM.style.width = Number.isInteger(width)
35
+ ? `${width}px`
36
+ : width
37
+ }
38
+ selectDOM.classList.forEach(className =>
39
+ buttonDOM.classList.add(className)
40
+ )
41
+ if (selectDOM.id) {
42
+ buttonDOM.id = selectDOM.id
43
+ }
44
+ selectDOM.parentElement.replaceChild(buttonDOM, selectDOM) // Remove the <select> from the main dom.
45
+ }
46
+
47
+ buttonDOM.setAttribute("role", "button")
48
+ buttonDOM.setAttribute("tabindex", "0")
49
+ buttonDOM.setAttribute("aria-haspopup", "true")
50
+ buttonDOM.setAttribute("aria-expanded", "false")
51
+
52
+ const options = Array.from(selectDOM.children)
53
+ if (!options.length) {
54
+ // There are no options, so we only create the button.
55
+ return {
56
+ setValue: () => {},
57
+ getValue: () => false
58
+ }
59
+ }
60
+ let selected
61
+ const menu = {
62
+ content: options.map((option, order) => {
63
+ if (option.selected || option.value === value) {
64
+ selected = option
65
+ }
66
+ return {
67
+ title: option.innerHTML,
68
+ type: "action",
69
+ tooltip: option.title || "",
70
+ order,
71
+ action: () => {
72
+ if (!button) {
73
+ buttonDOM.firstElementChild.innerText = option.innerText
74
+ }
75
+ value = option.value || option.dataset.value
76
+ onChange(value)
77
+ menu.content.forEach(item => (item.selected = false))
78
+ menu.content[order].selected = true
79
+ return false
80
+ },
81
+ selected: !!(option.selected || option.dataset.selected)
82
+ }
83
+ })
84
+ }
85
+ if (!selected && !button) {
86
+ selected = selectDOM.firstElementChild
87
+ menu.content[0].selected = true
88
+ }
89
+
90
+ if (!button) {
91
+ buttonDOM.firstElementChild.innerText = selected.innerText
92
+ }
93
+
94
+ value = selected ? selected.value : false
95
+
96
+ const openMenu = event => {
97
+ if (!isActivationEvent(event)) {
98
+ return
99
+ }
100
+
101
+ event.preventDefault()
102
+ event.stopPropagation()
103
+ if (buttonDOM.classList.contains("disabled")) {
104
+ return
105
+ }
106
+ // Determine menu position
107
+ let menuPos
108
+ if (event.type === "click") {
109
+ menuPos = {X: event.pageX, Y: event.pageY}
110
+ } else {
111
+ // Keyboard event
112
+ const rect = buttonDOM.getBoundingClientRect()
113
+ menuPos = {
114
+ X: rect.left + window.pageXOffset,
115
+ Y: rect.top + window.pageYOffset + rect.height
116
+ }
117
+ }
118
+
119
+ buttonDOM.setAttribute("aria-expanded", "true")
120
+ const contentMenu = new ContentMenu({
121
+ menu,
122
+ menuPos,
123
+ onClose: () => buttonDOM.setAttribute("aria-expanded", "false")
124
+ })
125
+ contentMenu.open()
126
+ }
127
+
128
+ buttonDOM.addEventListener("click", openMenu)
129
+ buttonDOM.addEventListener("keydown", openMenu)
130
+
131
+ return {
132
+ setValue: newValue => {
133
+ const optionIndex = options.findIndex(
134
+ option => option.value === newValue
135
+ )
136
+ if (optionIndex === undefined) {
137
+ return
138
+ }
139
+ menu.content.forEach(item => (item.selected = false))
140
+ menu.content[optionIndex].selected = true
141
+ const option = options[optionIndex]
142
+ if (!button) {
143
+ buttonDOM.firstElementChild.innerText = option.innerText
144
+ }
145
+ value = newValue
146
+ },
147
+ getValue: () => value,
148
+ enable: () => buttonDOM.classList.remove("disabled"),
149
+ disable: () => buttonDOM.classList.add("disabled")
150
+ }
151
+ }
152
+
153
+ /** Checks or unchecks a checkable label. This is used for example for bibliography categories when editing bibliography items.
154
+ * @param label The node who's parent has to be checked or unchecked.
155
+ */
156
+ export const setCheckableLabel = labelEl => {
157
+ if (labelEl.classList.contains("checked")) {
158
+ labelEl.classList.remove("checked")
159
+ } else {
160
+ labelEl.classList.add("checked")
161
+ }
162
+ }
163
+
164
+ let messageWaiter = false
165
+ let waitMessage = ""
166
+ /** Cover the page signaling to the user to wait.
167
+ */
168
+ export const activateWait = (full = false, message = "") => {
169
+ const waitEl = document.getElementById("wait")
170
+ if (message) {
171
+ let messageEl = waitEl.querySelector("span.message")
172
+ if (messageEl) {
173
+ // Another message is already showing. We update directly.
174
+ messageEl.innerText = message
175
+ } else {
176
+ waitMessage = message // We update the message if there is one waiting already
177
+ if (!messageWaiter) {
178
+ messageWaiter = setTimeout(() => {
179
+ messageEl = document.createElement("span")
180
+ messageEl.classList.add("message")
181
+ messageEl.innerText = waitMessage
182
+ waitEl.appendChild(messageEl)
183
+ messageWaiter = false
184
+ }, 2000)
185
+ }
186
+ }
187
+ }
188
+ if (waitEl) {
189
+ waitEl.classList.add("active")
190
+ if (full) {
191
+ waitEl.classList.add("full")
192
+ }
193
+ }
194
+ }
195
+
196
+ /** Remove the wait cover.
197
+ */
198
+ export const deactivateWait = () => {
199
+ const waitEl = document.getElementById("wait")
200
+ if (waitEl) {
201
+ waitEl.classList.remove("active")
202
+ waitEl.classList.remove("full")
203
+ }
204
+ const messageEl = waitEl.querySelector("span.message")
205
+ if (messageEl) {
206
+ messageEl.parentElement.removeChild(messageEl)
207
+ }
208
+ if (messageWaiter) {
209
+ clearTimeout(messageWaiter)
210
+ messageWaiter = false
211
+ }
212
+ }
213
+
214
+ /** Show a message to the user.
215
+ * @param alertType The type of message that is shown (error, warning, info or success).
216
+ * @param alertMsg The message text.
217
+ */
218
+ export const addAlert = (alertType, alertMsg) => {
219
+ if (!document.body) {
220
+ return
221
+ }
222
+ const iconNames = {
223
+ error: "circle-exclamation",
224
+ warning: "circle-exclamation",
225
+ info: "circle-info",
226
+ success: "circle-check"
227
+ }
228
+ if (!document.getElementById("#alerts-outer-wrapper")) {
229
+ document.body.insertAdjacentHTML(
230
+ "beforeend",
231
+ '<div id="alerts-outer-wrapper"><ul id="alerts-wrapper"></ul></div>'
232
+ )
233
+ }
234
+ const alertsWrapper = document.getElementById("alerts-wrapper")
235
+ alertsWrapper.insertAdjacentHTML(
236
+ "beforeend",
237
+ `<li class="alerts-${alertType} fa-before fa-${iconNames[alertType]}">${alertMsg}</li>`
238
+ )
239
+ const alertBox = alertsWrapper.lastElementChild
240
+ setTimeout(() => {
241
+ alertBox.classList.add("visible")
242
+ setTimeout(() => {
243
+ alertBox.classList.remove("visible")
244
+ setTimeout(() => alertsWrapper.removeChild(alertBox), 2000)
245
+ }, 4000)
246
+ }, 1)
247
+ }
248
+
249
+ // Used for system mesages
250
+ export const showSystemMessage = (message, buttons = [{type: "close"}]) => {
251
+ const dialog = new Dialog({
252
+ title: gettext("System message"),
253
+ body: `<p>${escapeText(message)}</p>`,
254
+ buttons
255
+ })
256
+ dialog.open()
257
+ return dialog
258
+ }
259
+
260
+ /** Turn milliseconds since epoch (UTC) into a local date string.
261
+ * @param {number} milliseconds Number of milliseconds since epoch (1/1/1970 midnight, UTC).
262
+ * @param {boolean} type 'full' for full date (default), 'sortable-date' for sortable date, 'minutes' for minute accuracy
263
+ */
264
+ const CACHED_DATES = {
265
+ "sortable-date": {},
266
+ minutes: {},
267
+ full: {}
268
+ }
269
+ export const localizeDate = (milliseconds, type = "full") => {
270
+ if (milliseconds === 0) {
271
+ return ""
272
+ } else if (CACHED_DATES[type][milliseconds]) {
273
+ return CACHED_DATES[type][milliseconds]
274
+ }
275
+ const theDate = new Date(milliseconds)
276
+ let returnValue
277
+ switch (type) {
278
+ case "sortable-date": {
279
+ const yyyy = theDate.getFullYear()
280
+ const mm = theDate.getMonth() + 1
281
+ const dd = theDate.getDate()
282
+ returnValue = `${yyyy}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`
283
+ break
284
+ }
285
+ case "minutes":
286
+ returnValue = theDate.toLocaleString([], {
287
+ year: "numeric",
288
+ month: "2-digit",
289
+ day: "2-digit",
290
+ hour: "2-digit",
291
+ minute: "2-digit"
292
+ })
293
+ break
294
+ default:
295
+ returnValue = theDate.toLocaleString()
296
+ }
297
+ if (Object.keys(CACHED_DATES[type]).length > 5000) {
298
+ CACHED_DATES[type] = {}
299
+ }
300
+ CACHED_DATES[type][milliseconds] = returnValue
301
+ return returnValue
302
+ }
303
+
304
+ /**
305
+ * Turn string literals into single line, removing spaces at start of line
306
+ */
307
+
308
+ export const noSpaceTmp = (strings, ...values) => {
309
+ const tmpStrings = Array.from(strings)
310
+
311
+ let combined = ""
312
+ while (tmpStrings.length > 0 || values.length > 0) {
313
+ if (tmpStrings.length > 0) {
314
+ combined += tmpStrings.shift()
315
+ }
316
+ if (values.length > 0) {
317
+ combined += values.shift()
318
+ }
319
+ }
320
+
321
+ let out = ""
322
+ combined.split("\n").forEach(line => {
323
+ out += line.replace(/^\s*/g, "")
324
+ })
325
+ return out
326
+ }
327
+
328
+ export const escapeText = text => {
329
+ return text
330
+ .replace(/&/g, "&amp;")
331
+ .replace(/</g, "&lt;")
332
+ .replace(/>/g, "&gt;")
333
+ .replace(/"/g, "&quot;")
334
+
335
+ .replace(/[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]/g, "") // invalid in XML chars
336
+ }
337
+
338
+ /**
339
+ * Return an inline info-icon with a hover tooltip containing the given HTML.
340
+ * Use only with trusted HTML content.
341
+ *
342
+ * @param {string} html - The tooltip content (HTML string)
343
+ * @returns {string} HTML for the info tooltip
344
+ */
345
+ export const infoTooltip = html =>
346
+ `<span class="fw-info-tooltip"><i class="fa-solid fa-info-circle"></i><span class="fw-info-tooltip-text">${html}</span></span>`
347
+
348
+ export const unescapeText = text =>
349
+ text
350
+ .replace(/&lt;/g, "<")
351
+ .replace(/&gt;/g, ">")
352
+ .replace(/&quot;/g, '"')
353
+ .replace(/&amp;/g, "&")
354
+ /**
355
+ * Return a cancel promise if you need to cancel a promise chain. Import as
356
+ * ES6 promises are not (yet) cancelable.
357
+ */
358
+
359
+ export const cancelPromise = () => new Promise(() => {})
360
+
361
+ // Check if selector matches one of the ancestors of the event target.
362
+ // Used in switch statements of document event listeners.
363
+ export const findTarget = (event, selector, el = {}) => {
364
+ el.target = event.target.closest(selector)
365
+ if (el.target) {
366
+ event.stopPropagation()
367
+ return true
368
+ }
369
+ return false
370
+ }
371
+
372
+ // Promise when page has been loaded.
373
+ export const whenReady = () => {
374
+ if (document.readyState === "complete") {
375
+ return Promise.resolve()
376
+ } else {
377
+ return new Promise(resolve => {
378
+ document.addEventListener("readystatechange", _event => {
379
+ if (document.readyState === "complete") {
380
+ resolve()
381
+ }
382
+ })
383
+ })
384
+ }
385
+ }
386
+
387
+ export const setDocTitle = (title, app) => {
388
+ const titleText = `${title} - ${app.name}`
389
+ if (document.title !== titleText) {
390
+ document.title = titleText
391
+ }
392
+ }
393
+
394
+ const LANGUAGES = {
395
+ ar: "العربية",
396
+ bg: "Български",
397
+ cs: "Čeština",
398
+ da: "Dansk",
399
+ de: "Deutsch",
400
+ en: "English",
401
+ es: "Español",
402
+ fr: "Français",
403
+ it: "Italiano",
404
+ ja: "日本語",
405
+ ko: "한국어",
406
+ nb: "Norsk bokmål",
407
+ nl: "Nederlands",
408
+ pl: "Polski",
409
+ "pt-br": "Português (Brasil)",
410
+ "pt-pt": "Português (Portugal)",
411
+ ru: "Русский",
412
+ sv: "Svenska",
413
+ tr: "Türkçe",
414
+ "zh-hans": "简体中文"
415
+ }
416
+
417
+ export const langName = code => {
418
+ return LANGUAGES[code] || code
419
+ }
420
+
421
+ /** Enable ISO date picker on a text input by overlaying a native date picker.
422
+ * The text input displays ISO format (YYYY-MM-DD) while using the native picker for selection.
423
+ * @param {HTMLInputElement} inputEl - The text input element to enhance
424
+ * @param {boolean} minToday - If true, sets min date to today (default: false)
425
+ */
426
+ export const enableDatePicker = (inputEl, minToday = false) => {
427
+ const datePicker = document.createElement("input")
428
+ datePicker.type = "date"
429
+ if (minToday) {
430
+ datePicker.min = new Date().toISOString().split("T")[0]
431
+ }
432
+ datePicker.style.position = "absolute"
433
+ datePicker.style.opacity = "0"
434
+ datePicker.style.pointerEvents = "none"
435
+
436
+ const parent = inputEl.parentElement
437
+ parent.style.position = "relative"
438
+ parent.appendChild(datePicker)
439
+
440
+ inputEl.addEventListener("click", () => datePicker.showPicker())
441
+ datePicker.addEventListener("change", () => {
442
+ inputEl.value = datePicker.value
443
+ })
444
+
445
+ // Validate and normalize date on blur or form submission
446
+ const validateDate = () => {
447
+ const value = inputEl.value
448
+ if (!value) {
449
+ return // Empty is valid (optional field)
450
+ }
451
+ const date = new Date(value)
452
+ if (isNaN(date.getTime())) {
453
+ inputEl.value = "" // Invalid date, clear it
454
+ return
455
+ }
456
+ // Re-format to ensure consistent YYYY-MM-DD format
457
+ const yyyy = date.getFullYear()
458
+ const mm = String(date.getMonth() + 1).padStart(2, "0")
459
+ const dd = String(date.getDate()).padStart(2, "0")
460
+ inputEl.value = `${yyyy}-${mm}-${dd}`
461
+ }
462
+
463
+ inputEl.addEventListener("blur", validateDate)
464
+ inputEl.addEventListener("keydown", event => {
465
+ // Intercept Enter to validate before form submission
466
+ if (event.key === "Enter") {
467
+ validateDate()
468
+ return
469
+ }
470
+ // Allow typing when date picker is open (it takes focus)
471
+ if (document.activeElement === datePicker) {
472
+ return // Let datePicker handle all keys
473
+ }
474
+ const key = event.key
475
+ // Open picker on Enter
476
+ if (key === "Enter") {
477
+ event.preventDefault()
478
+ datePicker.showPicker()
479
+ return
480
+ }
481
+ // Allow editing: digits, dashes, backspace, delete, arrow keys
482
+ if (
483
+ /^\d$/.test(key) ||
484
+ key === "-" ||
485
+ key === "Backspace" ||
486
+ key === "Delete" ||
487
+ key === "ArrowLeft" ||
488
+ key === "ArrowRight" ||
489
+ key === "Tab"
490
+ ) {
491
+ return // Allow default behavior
492
+ }
493
+ // Block other keys
494
+ event.preventDefault()
495
+ })
496
+ // Allow typing while date picker is open
497
+ datePicker.addEventListener("keydown", event => {
498
+ event.stopPropagation()
499
+ })
500
+ }
package/src/blob.js CHANGED
@@ -1,4 +1,4 @@
1
- export const convertDataURIToBlob = dataURI => {
1
+ export function convertDataURIToBlob(dataURI) {
2
2
  const byteString = atob(dataURI.split(",")[1])
3
3
  const mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0]
4
4
  const ab = new ArrayBuffer(byteString.length)