@zseven-w/pen-core 0.0.1

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,269 @@
1
+ import type { PenNode } from '@zseven-w/pen-types'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Sizing parser (shared by layout engine and text height estimation)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /** Parse a sizing value. Handles number, "fit_content", "fill_container" and parenthesized forms. */
8
+ export function parseSizing(value: unknown): number | 'fit' | 'fill' {
9
+ if (typeof value === 'number') return value
10
+ if (typeof value !== 'string') return 0
11
+ if (value.startsWith('fill_container')) return 'fill'
12
+ if (value.startsWith('fit_content')) return 'fit'
13
+ const n = parseFloat(value)
14
+ return isNaN(n) ? 0 : n
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Default line height — single source of truth for all modules
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Canonical default lineHeight when a text node has no explicit value.
23
+ * Display/heading text (>=28px) gets tighter spacing; body text gets looser.
24
+ * All modules (factory, layout engine, text estimation, AI generation)
25
+ * MUST use this function instead of hardcoded fallbacks.
26
+ */
27
+ export function defaultLineHeight(fontSize: number): number {
28
+ if (fontSize >= 40) return 1.0 // Display text: tight leading (matches Pencil 0.9-1.0)
29
+ if (fontSize >= 28) return 1.15 // Heading text: moderate (matches Pencil 1.0-1.2)
30
+ if (fontSize >= 20) return 1.2 // Subheading
31
+ return 1.5 // Body text: comfortable reading
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // CJK detection
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export function isCjkCodePoint(code: number): boolean {
39
+ return (code >= 0x4E00 && code <= 0x9FFF) // CJK Unified Ideographs
40
+ || (code >= 0x3400 && code <= 0x4DBF) // CJK Extension A
41
+ || (code >= 0x3040 && code <= 0x30FF) // Hiragana + Katakana
42
+ || (code >= 0xAC00 && code <= 0xD7AF) // Hangul
43
+ || (code >= 0x3000 && code <= 0x303F) // CJK symbols/punctuation
44
+ || (code >= 0xFF00 && code <= 0xFFEF) // Full-width forms
45
+ }
46
+
47
+ export function hasCjkText(text: string): boolean {
48
+ for (const ch of text) {
49
+ const code = ch.codePointAt(0) ?? 0
50
+ if (isCjkCodePoint(code)) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Glyph / line width estimation
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Font weight multiplier — bold/semibold text is wider than regular text.
61
+ * Values based on typical proportional font width scaling.
62
+ */
63
+ function fontWeightFactor(fontWeight?: string | number): number {
64
+ const w = typeof fontWeight === 'string' ? parseInt(fontWeight, 10) : (fontWeight ?? 400)
65
+ if (isNaN(w) || w <= 400) return 1.0
66
+ if (w <= 500) return 1.03
67
+ if (w <= 600) return 1.06
68
+ if (w <= 700) return 1.09
69
+ return 1.12
70
+ }
71
+
72
+ export function estimateGlyphWidth(ch: string, fontSize: number, fontWeight?: string | number): number {
73
+ if (ch === '\n' || ch === '\r') return 0
74
+ if (ch === '\t') return fontSize * 1.2
75
+ if (ch === ' ') return fontSize * 0.33
76
+
77
+ const wf = fontWeightFactor(fontWeight)
78
+ const code = ch.codePointAt(0) ?? 0
79
+ if (isCjkCodePoint(code)) return fontSize * 1.12 * wf
80
+ if (/[A-Z]/.test(ch)) return fontSize * 0.62 * wf
81
+ if (/[a-z]/.test(ch)) return fontSize * 0.56 * wf
82
+ if (/[0-9]/.test(ch)) return fontSize * 0.56 * wf
83
+ return fontSize * 0.58 * wf
84
+ }
85
+
86
+ export function estimateLineWidth(
87
+ text: string,
88
+ fontSize: number,
89
+ letterSpacing = 0,
90
+ fontWeight?: string | number,
91
+ ): number {
92
+ let width = 0
93
+ let visibleChars = 0
94
+ for (const ch of text) {
95
+ width += estimateGlyphWidth(ch, fontSize, fontWeight)
96
+ if (ch !== '\n' && ch !== '\r') visibleChars += 1
97
+ }
98
+ if (visibleChars > 1 && letterSpacing !== 0) {
99
+ width += (visibleChars - 1) * letterSpacing
100
+ }
101
+ return Math.max(0, width)
102
+ }
103
+
104
+ export function widthSafetyFactor(text: string): number {
105
+ // Latin fonts vary a lot by weight/family; use a larger safety margin to
106
+ // avoid underestimating width and causing accidental wraps.
107
+ return hasCjkText(text) ? 1.06 : 1.14
108
+ }
109
+
110
+ export function estimateTextWidth(text: string, fontSize: number, letterSpacing = 0, fontWeight?: string | number): number {
111
+ const lines = text.split(/\r?\n/)
112
+ const maxLine = lines.reduce((max, line) => {
113
+ const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight)
114
+ const safeLineWidth = lineWidth * widthSafetyFactor(line)
115
+ return Math.max(max, safeLineWidth)
116
+ }, 0)
117
+ return maxLine
118
+ }
119
+
120
+ /**
121
+ * Estimate text width WITHOUT safety factor.
122
+ * Used for layout centering where the safety margin causes text to appear
123
+ * off-center (the overestimated width shifts the text box left when centered).
124
+ * For wrapping/sizing decisions, use estimateTextWidth() which includes the safety factor.
125
+ */
126
+ export function estimateTextWidthPrecise(text: string, fontSize: number, letterSpacing = 0, fontWeight?: string | number): number {
127
+ const lines = text.split(/\r?\n/)
128
+ return lines.reduce((max, line) => {
129
+ return Math.max(max, estimateLineWidth(line, fontSize, letterSpacing, fontWeight))
130
+ }, 0)
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Text content helpers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ export function resolveTextContent(node: PenNode): string {
138
+ if (node.type !== 'text') return ''
139
+ return typeof node.content === 'string'
140
+ ? node.content
141
+ : node.content.map((s) => s.text).join('')
142
+ }
143
+
144
+ export function countExplicitTextLines(text: string): number {
145
+ if (!text) return 1
146
+ return Math.max(1, text.split(/\r?\n/).length)
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Optical vertical correction for centered single-line text
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Optical vertical correction for centered single-line text.
155
+ * Within the Fabric text bounding box (fontSize * 1.13), glyph ink sits
156
+ * slightly above the mathematical center due to ascent/descent asymmetry.
157
+ * We nudge down proportionally to compensate.
158
+ */
159
+ export function getTextOpticalCenterYOffset(node: PenNode): number {
160
+ if (node.type !== 'text') return 0
161
+ const text = resolveTextContent(node).trim()
162
+ if (!text) return 0
163
+ if (countExplicitTextLines(text) > 1) return 0
164
+
165
+ const fontSize = node.fontSize ?? 16
166
+ const hasCjk = hasCjkText(text)
167
+
168
+ // CJK glyphs sit higher in the em box than Latin glyphs
169
+ const ratio = hasCjk ? 0.06 : 0.03
170
+ const offset = fontSize * ratio
171
+ return Math.max(0, Math.min(Math.round(fontSize * 0.05), Math.round(offset)))
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Wrapped line count — injectable for browser/non-browser environments
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Count wrapped lines using character-width estimation fallback.
180
+ * This is the pure (non-browser) implementation.
181
+ */
182
+ export function countWrappedLinesFallback(
183
+ rawLines: string[],
184
+ wrapWidth: number,
185
+ fontSize: number,
186
+ letterSpacing: number,
187
+ fontWeight: string | number | undefined,
188
+ ): number {
189
+ return rawLines.reduce((sum, line) => {
190
+ const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line)
191
+ return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth))
192
+ }, 0)
193
+ }
194
+
195
+ /**
196
+ * Injectable wrapped line counter. Browser environments can replace this
197
+ * with a Canvas 2D-based implementation for accurate word-wrap prediction.
198
+ */
199
+ export type WrappedLineCounter = (
200
+ rawLines: string[],
201
+ wrapWidth: number,
202
+ fontSize: number,
203
+ fontWeight: string | number | undefined,
204
+ fontFamily: string,
205
+ letterSpacing: number,
206
+ ) => number
207
+
208
+ let _wrappedLineCounter: WrappedLineCounter | null = null
209
+
210
+ /** Set a custom wrapped line counter (e.g. Canvas 2D-based). */
211
+ export function setWrappedLineCounter(counter: WrappedLineCounter): void {
212
+ _wrappedLineCounter = counter
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Text height estimation (multi-line wrapping aware)
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /** Estimate text height including multi-line wrapping when available width is known. */
220
+ export function estimateTextHeight(node: PenNode, availableWidth?: number): number {
221
+ // Access text-specific properties via Record to avoid union type issues
222
+ const n = node as unknown as Record<string, unknown>
223
+ const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
224
+ const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize))
225
+ // Fabric.js uses _fontSizeMult = 1.13 for the glyph height of a single line.
226
+ // lineHeight spacing applies *between* lines, not below the last line.
227
+ const FABRIC_FONT_MULT = 1.13
228
+ const glyphH = fontSize * FABRIC_FONT_MULT
229
+ const lineStep = fontSize * lineHeight
230
+
231
+ // Get text content
232
+ const rawContent = n.content
233
+ const content = typeof rawContent === 'string'
234
+ ? rawContent
235
+ : Array.isArray(rawContent)
236
+ ? rawContent.map((s: { text: string }) => s.text).join('')
237
+ : ''
238
+ if (!content) return glyphH
239
+
240
+ // Determine the effective text width for wrapping estimation
241
+ let textWidth = 0
242
+ if ('width' in node) {
243
+ const w = parseSizing(node.width)
244
+ if (typeof w === 'number' && w > 0) textWidth = w
245
+ else if (w === 'fill' && availableWidth && availableWidth > 0) textWidth = availableWidth
246
+ }
247
+
248
+ // If no width constraint is known, still count explicit newlines
249
+ if (textWidth <= 0) {
250
+ const explicitLines = content.split(/\r?\n/).length
251
+ const n2 = Math.max(1, explicitLines)
252
+ return Math.round(n2 <= 1 ? glyphH : (n2 - 1) * lineStep + glyphH)
253
+ }
254
+
255
+ // Use custom wrapped line counter if set (e.g. Canvas 2D), else fallback
256
+ const fontWeight = n.fontWeight as string | number | undefined
257
+ const fontFamily = (typeof n.fontFamily === 'string' ? n.fontFamily : '') || 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
258
+ const letterSpacing = (typeof n.letterSpacing === 'number' ? n.letterSpacing : 0)
259
+ const rawLines = content.split(/\r?\n/)
260
+ // Add tolerance matching the renderer's wrapLine (w + fontSize * 0.2)
261
+ const wrapWidth = textWidth + fontSize * 0.2
262
+
263
+ const wrappedLineCount = _wrappedLineCounter
264
+ ? _wrappedLineCounter(rawLines, wrapWidth, fontSize, fontWeight, fontFamily, letterSpacing)
265
+ : countWrappedLinesFallback(rawLines, wrapWidth, fontSize, letterSpacing, fontWeight)
266
+
267
+ const totalLines = Math.max(1, wrappedLineCount)
268
+ return Math.round(totalLines <= 1 ? glyphH : (totalLines - 1) * lineStep + glyphH)
269
+ }
@@ -0,0 +1,14 @@
1
+ import type { PenNode } from '@zseven-w/pen-types'
2
+
3
+ /**
4
+ * Check if a node is a badge/overlay that uses absolute positioning
5
+ * and should not participate in layout flow.
6
+ */
7
+ export function isBadgeOverlayNode(node: PenNode): boolean {
8
+ if ('role' in node) {
9
+ const role = (node as { role?: string }).role
10
+ if (role === 'badge' || role === 'pill' || role === 'tag') return true
11
+ }
12
+ const name = (node.name ?? '').toLowerCase()
13
+ return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
14
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Normalize a Pencil.dev .pen document into OpenPencil's internal format.
3
+ *
4
+ * Handles format normalization ONLY — does NOT resolve $variable references:
5
+ * - fill type: "color" → "solid"
6
+ * - fill shorthand string "#hex" → [{ type: "solid", color }]
7
+ * - gradient type: "gradient" → "linear_gradient" / "radial_gradient"
8
+ * - gradient stops { color, position } → { offset, color }
9
+ * - sizing "fit_content(N)" / "fill_container(N)" → fallback number
10
+ * - padding array normalization
11
+ *
12
+ * Variable resolution is handled separately by `resolve-variables.ts` at
13
+ * canvas render time, preserving $variable bindings in the document.
14
+ */
15
+
16
+ import type { PenDocument, PenNode } from '@zseven-w/pen-types'
17
+ import type { PenFill, PenStroke, GradientStop } from '@zseven-w/pen-types'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Public API
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export function normalizePenDocument(doc: PenDocument): PenDocument {
24
+ const normalized = {
25
+ ...doc,
26
+ children: doc.children.map((n) => normalizeNode(n)),
27
+ }
28
+ // Normalize all pages' children too
29
+ if (normalized.pages && normalized.pages.length > 0) {
30
+ normalized.pages = normalized.pages.map((p) => ({
31
+ ...p,
32
+ children: p.children.map((n) => normalizeNode(n)),
33
+ }))
34
+ }
35
+ return normalized
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Node normalizer (recursive)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function normalizeNode(node: PenNode): PenNode {
43
+ const out: Record<string, unknown> = { ...node }
44
+
45
+ // fill
46
+ if ('fill' in out && out.fill !== undefined) {
47
+ out.fill = normalizeFills(out.fill)
48
+ }
49
+
50
+ // stroke
51
+ if ('stroke' in out && out.stroke != null) {
52
+ out.stroke = normalizeStroke(out.stroke as Record<string, unknown>)
53
+ }
54
+
55
+ // effects — pass through (no format changes needed)
56
+
57
+ // sizing
58
+ if ('width' in out) out.width = normalizeSizing(out.width)
59
+ if ('height' in out) out.height = normalizeSizing(out.height)
60
+
61
+ // gap — pass through ($variable strings preserved)
62
+
63
+ // padding — normalize array format only (not variable resolution)
64
+ if ('padding' in out) out.padding = normalizePadding(out.padding)
65
+
66
+ // opacity — pass through ($variable strings preserved)
67
+
68
+ // icon_font: default to lucide family
69
+ if (out.type === 'icon_font' && !out.iconFontFamily) {
70
+ out.iconFontFamily = 'lucide'
71
+ }
72
+
73
+ // children
74
+ if ('children' in out && Array.isArray(out.children)) {
75
+ out.children = (out.children as PenNode[]).map((c) => normalizeNode(c))
76
+ }
77
+
78
+ return out as unknown as PenNode
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Fill normalization
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function normalizeFills(raw: unknown): PenFill[] {
86
+ if (!raw) return []
87
+
88
+ // String shorthand: "#hex" or "$variable" → solid fill
89
+ if (typeof raw === 'string') {
90
+ return [{ type: 'solid', color: raw }]
91
+ }
92
+
93
+ // Array of fills
94
+ if (Array.isArray(raw)) {
95
+ return raw.map((f) => normalizeSingleFill(f)).filter(Boolean) as PenFill[]
96
+ }
97
+
98
+ // Single fill object
99
+ if (typeof raw === 'object') {
100
+ const f = normalizeSingleFill(raw as Record<string, unknown>)
101
+ return f ? [f] : []
102
+ }
103
+
104
+ return []
105
+ }
106
+
107
+ function normalizeSingleFill(
108
+ raw: Record<string, unknown> | string,
109
+ ): PenFill | null {
110
+ // String shorthand inside array: "#hex" or "$variable" → solid fill
111
+ if (typeof raw === 'string') {
112
+ return raw ? { type: 'solid', color: raw } : null
113
+ }
114
+ if (!raw || typeof raw !== 'object') return null
115
+ const t = raw.type as string | undefined
116
+
117
+ // Pencil "color" → OpenPencil "solid"
118
+ if (t === 'color' || t === 'solid') {
119
+ return {
120
+ type: 'solid',
121
+ color: typeof raw.color === 'string' ? raw.color : '#000000',
122
+ }
123
+ }
124
+
125
+ // Pencil "gradient" → split by gradientType
126
+ if (t === 'gradient') {
127
+ const gt = (raw.gradientType as string) ?? 'linear'
128
+ const stops = normalizeGradientStops(raw.colors as unknown[])
129
+
130
+ if (gt === 'radial') {
131
+ const center = raw.center as Record<string, unknown> | undefined
132
+ return {
133
+ type: 'radial_gradient',
134
+ cx: typeof center?.x === 'number' ? center.x : 0.5,
135
+ cy: typeof center?.y === 'number' ? center.y : 0.5,
136
+ radius: 0.5,
137
+ stops,
138
+ }
139
+ }
140
+ // linear or angular
141
+ return {
142
+ type: 'linear_gradient',
143
+ angle: typeof raw.rotation === 'number' ? raw.rotation : 0,
144
+ stops,
145
+ }
146
+ }
147
+
148
+ // Already our format
149
+ if (t === 'linear_gradient' || t === 'radial_gradient') {
150
+ const stops =
151
+ 'stops' in raw
152
+ ? normalizeGradientStops(raw.stops as unknown[])
153
+ : 'colors' in raw
154
+ ? normalizeGradientStops(raw.colors as unknown[])
155
+ : []
156
+ return { ...(raw as unknown as PenFill), stops } as PenFill
157
+ }
158
+
159
+ // Image fill — pass through
160
+ if (t === 'image') return raw as unknown as PenFill
161
+
162
+ // Fallback: if there's a color field, treat as solid
163
+ if ('color' in raw) {
164
+ return {
165
+ type: 'solid',
166
+ color: typeof raw.color === 'string' ? raw.color : '#000000',
167
+ }
168
+ }
169
+
170
+ return null
171
+ }
172
+
173
+ function normalizeGradientStops(
174
+ raw: unknown[] | undefined,
175
+ ): GradientStop[] {
176
+ if (!Array.isArray(raw) || raw.length === 0) return []
177
+
178
+ // First pass: parse offsets, collecting which ones are explicitly set
179
+ const parsed = raw.map((s: unknown) => {
180
+ const stop = s as Record<string, unknown>
181
+ const rawOffset =
182
+ typeof stop.offset === 'number' && Number.isFinite(stop.offset)
183
+ ? stop.offset
184
+ : typeof stop.position === 'number' && Number.isFinite(stop.position)
185
+ ? stop.position
186
+ : null
187
+ // Normalize percentage-format offsets (AI sometimes outputs 0-100 instead of 0-1)
188
+ const offset = rawOffset !== null && rawOffset > 1 ? rawOffset / 100 : rawOffset
189
+ return {
190
+ offset,
191
+ color: typeof stop.color === 'string' ? stop.color : '#000000',
192
+ }
193
+ })
194
+
195
+ // Second pass: auto-distribute any stops that are missing an offset
196
+ const n = parsed.length
197
+ return parsed.map((s, i) => ({
198
+ color: s.color,
199
+ offset: s.offset !== null ? Math.max(0, Math.min(1, s.offset!)) : i / Math.max(n - 1, 1),
200
+ }))
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Stroke normalization
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function normalizeStroke(
208
+ raw: Record<string, unknown>,
209
+ ): PenStroke | undefined {
210
+ if (!raw) return undefined
211
+ const out = { ...raw }
212
+
213
+ // Normalize fill inside stroke
214
+ if ('fill' in out) {
215
+ out.fill = normalizeFills(out.fill)
216
+ }
217
+
218
+ // Pencil may use "color" directly on stroke
219
+ if ('color' in out && typeof out.color === 'string') {
220
+ out.fill = [{ type: 'solid', color: out.color as string }]
221
+ delete out.color
222
+ }
223
+
224
+ // Thickness: leave $variable strings as-is, normalise plain number strings
225
+ if (typeof out.thickness === 'string') {
226
+ const str = out.thickness as string
227
+ if (!str.startsWith('$')) {
228
+ const num = parseFloat(str)
229
+ out.thickness = isNaN(num) ? 1 : num
230
+ }
231
+ }
232
+
233
+ return out as unknown as PenStroke
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Sizing normalization
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function normalizeSizing(value: unknown): number | string {
241
+ if (typeof value === 'number') return value
242
+ if (typeof value !== 'string') return 0
243
+
244
+ // $variable — pass through
245
+ if (value.startsWith('$')) return value
246
+
247
+ // fill_container must always resolve dynamically from parent dimensions
248
+ if (value.startsWith('fill_container')) return 'fill_container'
249
+
250
+ // fit_content with a hint value: use the hint (more accurate than our estimation)
251
+ if (value.startsWith('fit_content')) {
252
+ const match = value.match(/\((\d+(?:\.\d+)?)\)/)
253
+ if (match) return parseFloat(match[1])
254
+ return 'fit_content'
255
+ }
256
+
257
+ // Try as a plain number string
258
+ const num = parseFloat(value)
259
+ return isNaN(num) ? 0 : num
260
+ }
261
+
262
+ function normalizePadding(
263
+ value: unknown,
264
+ ): number | [number, number] | [number, number, number, number] | string | undefined {
265
+ if (typeof value === 'number') return value
266
+ if (typeof value === 'string') {
267
+ // $variable — pass through
268
+ if (value.startsWith('$')) return value
269
+ const num = parseFloat(value)
270
+ return isNaN(num) ? 0 : num
271
+ }
272
+ if (Array.isArray(value)) {
273
+ return value.map((v) => {
274
+ if (typeof v === 'number') return v
275
+ if (typeof v === 'string') {
276
+ const num = parseFloat(v)
277
+ return isNaN(num) ? 0 : num
278
+ }
279
+ return 0
280
+ }) as [number, number] | [number, number, number, number]
281
+ }
282
+ return undefined
283
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * When locked, document-store → Fabric sync is skipped (Fabric is the source).
3
+ *
4
+ * Uses a getter function instead of a bare `let` export so that cross-module
5
+ * reads always resolve the current value — even if the bundler does not
6
+ * preserve ES-module live bindings for `let` variables.
7
+ */
8
+ let _locked = false
9
+
10
+ export function isFabricSyncLocked(): boolean {
11
+ return _locked
12
+ }
13
+
14
+ export function setFabricSyncLock(v: boolean) {
15
+ _locked = v
16
+ }