pict-section-moodboard 0.1.0
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/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +30 -0
- package/source/Pict-Section-Moodboard.js +21 -0
- package/source/cards/MoodImage-Card.js +85 -0
- package/source/cards/MoodNote-Card.js +91 -0
- package/source/cards/MoodText-Card.js +90 -0
- package/source/sources/ImageSource-Base.js +173 -0
- package/source/views/PictView-Moodboard.js +800 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Moodboard: a free-form canvas of draggable, resizable image tiles and sticky notes.
|
|
5
|
+
*
|
|
6
|
+
* It is a thin layer over pict-section-flow: a flow view configured with no ports and no
|
|
7
|
+
* connections (EnableConnectionCreation off, card types declare no Inputs/Outputs), node resizing
|
|
8
|
+
* on (EnableNodeResizing, the corner grip), pan and zoom on, and a zero-height title bar so image
|
|
9
|
+
* and note cards fill edge to edge. Everything the canvas needs (drag, resize, pan, zoom, select,
|
|
10
|
+
* save and restore) comes from the flow; this view adds the moodboard toolbar, the two card types,
|
|
11
|
+
* and image input (a URL field, a file picker, drag-and-drop, and clipboard paste).
|
|
12
|
+
*
|
|
13
|
+
* Images are stored on each node as Data.ImageUrl. Stand-alone that is a base64 data URL kept right
|
|
14
|
+
* in the board JSON, so a board is fully self-contained. An embedding application can instead pass
|
|
15
|
+
* an ImageSource (options.ImageSource) that serves images from its own store with its own metadata;
|
|
16
|
+
* this view keeps the reference and leaves the bytes to the host.
|
|
17
|
+
*
|
|
18
|
+
* The board serializes through the flow's own getFlowData / setFlowData (Nodes + ViewState); there
|
|
19
|
+
* are no Connections.
|
|
20
|
+
*
|
|
21
|
+
* @author Steven Velozo <steven@velozo.com>
|
|
22
|
+
* @license MIT
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const libPictView = require('pict-view');
|
|
26
|
+
const libPictSectionFlow = require('pict-section-flow');
|
|
27
|
+
const libMoodImageCard = require('../cards/MoodImage-Card.js');
|
|
28
|
+
const libMoodNoteCard = require('../cards/MoodNote-Card.js');
|
|
29
|
+
const libMoodTextCard = require('../cards/MoodText-Card.js');
|
|
30
|
+
const libImageSource = require('../sources/ImageSource-Base.js');
|
|
31
|
+
|
|
32
|
+
// A small, friendly note palette. The first entry is the default for a new note.
|
|
33
|
+
const _NOTE_COLORS = ['#ffe08a', '#ffb3c1', '#a8d8ff', '#b7e4c7', '#d8c2ff', '#ffd6a5', '#e6e6e6'];
|
|
34
|
+
|
|
35
|
+
const _ViewConfiguration =
|
|
36
|
+
{
|
|
37
|
+
ViewIdentifier: 'Moodboard',
|
|
38
|
+
DefaultRenderable: 'Moodboard-Container',
|
|
39
|
+
DefaultDestinationAddress: '#Moodboard-Container',
|
|
40
|
+
CSS: /*css*/`
|
|
41
|
+
.mb-root { display: flex; flex-direction: column; height: 100%; min-height: 420px; }
|
|
42
|
+
.mb-toolbar { display: flex; align-items: center; gap: 0.4em; flex-wrap: wrap; padding: 8px 10px; border-bottom: 1px solid var(--theme-color-border-default, #dfe3ea); background: var(--theme-color-background-panel, #fff); }
|
|
43
|
+
.mb-btn { padding: 0.4em 0.7em; border: 1px solid var(--theme-color-border-default, #ccc); border-radius: 6px; background: var(--theme-color-background-panel, #fff); color: var(--theme-color-text-primary, #222); cursor: pointer; font-size: 0.9em; }
|
|
44
|
+
.mb-btn:hover { background: var(--theme-color-background-hover, #f2f2f2); }
|
|
45
|
+
.mb-btn-primary { background: var(--theme-color-brand-primary, #2880a6); border-color: var(--theme-color-brand-primary, #2880a6); color: #fff; }
|
|
46
|
+
.mb-url { padding: 0.4em 0.6em; border: 1px solid var(--theme-color-border-default, #ccc); border-radius: 6px; font-size: 0.9em; min-width: 180px; }
|
|
47
|
+
.mb-swatches { display: inline-flex; gap: 4px; align-items: center; }
|
|
48
|
+
.mb-swatch { width: 18px; height: 18px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.15); cursor: pointer; padding: 0; }
|
|
49
|
+
.mb-swatch:hover { transform: scale(1.12); }
|
|
50
|
+
.mb-sep { width: 1px; align-self: stretch; background: var(--theme-color-border-default, #e2e6ec); margin: 2px 4px; }
|
|
51
|
+
.mb-hint { font-size: 0.8em; color: var(--theme-color-text-secondary, #8a93a5); margin-left: auto; }
|
|
52
|
+
.mb-canvas { position: relative; flex: 1; min-height: 0; }
|
|
53
|
+
.mb-canvas.mb-dropping::after { content: "Drop images here"; position: absolute; inset: 10px; border: 2px dashed var(--theme-color-brand-primary, #2880a6); border-radius: 10px; display: flex; align-items: center; justify-content: center; color: var(--theme-color-brand-primary, #2880a6); font-weight: 600; pointer-events: none; background: rgba(40,128,166,0.06); }
|
|
54
|
+
.mb-flow { position: absolute; inset: 0; }
|
|
55
|
+
|
|
56
|
+
/* A moodboard has a flat, light canvas (no dark flow grid). */
|
|
57
|
+
.mb-flow .pict-flow-grid-background { fill: var(--theme-color-background-secondary, #f4f6f9); }
|
|
58
|
+
/* Moodboard cards fill edge to edge: no title text, no ports, transparent note text area. */
|
|
59
|
+
.pict-flow-node-MoodImage .pict-flow-node-title,
|
|
60
|
+
.pict-flow-node-MoodNote .pict-flow-node-title,
|
|
61
|
+
.pict-flow-node-MoodText .pict-flow-node-title { display: none; }
|
|
62
|
+
.pict-flow-node-MoodImage .pict-flow-port, .pict-flow-node-MoodNote .pict-flow-port, .pict-flow-node-MoodText .pict-flow-port,
|
|
63
|
+
.pict-flow-node-MoodImage .pict-flow-port-label, .pict-flow-node-MoodNote .pict-flow-port-label, .pict-flow-node-MoodText .pict-flow-port-label { display: none; }
|
|
64
|
+
.mb-image { width: 100%; height: 100%; display: block; border-radius: 8px; }
|
|
65
|
+
.mb-image-cover { object-fit: cover; }
|
|
66
|
+
.mb-image-contain { object-fit: contain; }
|
|
67
|
+
.mb-note { width: 100%; height: 100%; box-sizing: border-box; padding: 10px; font-family: inherit; font-size: 13px; line-height: 1.35; color: #3a3320; overflow: hidden; white-space: pre-wrap; word-break: break-word; }
|
|
68
|
+
.mb-note:empty::before, .mb-text:empty::before { content: attr(data-ph); color: rgba(0,0,0,0.28); }
|
|
69
|
+
|
|
70
|
+
/* Big-type Text card: bold, transparent (floats), centered; the font scales with the card box
|
|
71
|
+
(CSS container units) unless a fixed size is set. Make the card bigger and the words grow. */
|
|
72
|
+
.pict-flow-node-MoodText .pict-flow-node-body-content-html { container-type: size; width: 100%; height: 100%; }
|
|
73
|
+
.mb-text { width: 100%; height: 100%; box-sizing: border-box; padding: 4px 10px; display: flex; align-items: center; justify-content: center; text-align: center; font-family: inherit; font-weight: 800; line-height: 1.04; letter-spacing: -0.015em; color: var(--theme-color-text-primary, #1d2230); font-size: min(64cqh, 12cqw); overflow: hidden; white-space: pre-wrap; word-break: break-word; }
|
|
74
|
+
.pict-flow-node-MoodText .pict-flow-node-body { fill: transparent !important; stroke: none !important; }
|
|
75
|
+
.pict-flow-node-MoodText .pict-flow-node-title-bar, .pict-flow-node-MoodText .pict-flow-node-title-bar-bottom { fill: transparent !important; }
|
|
76
|
+
.pict-flow-node-MoodText { filter: none !important; }
|
|
77
|
+
|
|
78
|
+
/* Card bodies are display-only so the whole card drags; editing happens in the properties panel. */
|
|
79
|
+
.pict-flow-node-MoodImage .pict-flow-node-body-content-html,
|
|
80
|
+
.pict-flow-node-MoodNote .pict-flow-node-body-content-html,
|
|
81
|
+
.pict-flow-node-MoodText .pict-flow-node-body-content-html { pointer-events: none; }
|
|
82
|
+
|
|
83
|
+
/* Properties panel (double-click a card to open it). */
|
|
84
|
+
.mbp { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; font-size: 13px; }
|
|
85
|
+
.mbp-label { font-size: 11px; font-weight: 600; color: var(--theme-color-text-secondary, #5b6376); text-transform: uppercase; letter-spacing: 0.03em; }
|
|
86
|
+
.mbp-input { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--theme-color-border-default, #d8dde6); border-radius: 6px; font-size: 13px; font-family: inherit; background: var(--theme-color-background-panel, #fff); }
|
|
87
|
+
.mbp-textarea { min-height: 72px; resize: vertical; line-height: 1.3; }
|
|
88
|
+
.mbp-range { width: 100%; box-sizing: border-box; margin: 2px 0; }
|
|
89
|
+
.mbp-swatches { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
90
|
+
.mbp-swatch { width: 22px; height: 22px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.15); cursor: pointer; padding: 0; }
|
|
91
|
+
.mbp-swatch:hover { transform: scale(1.12); }
|
|
92
|
+
|
|
93
|
+
/* Gallery picker overlay (built from whatever fields the image source declares). */
|
|
94
|
+
.mb-gallery { display: none; position: absolute; inset: 0; z-index: 20; align-items: center; justify-content: center; }
|
|
95
|
+
.mb-gallery-open .mb-gallery { display: flex; }
|
|
96
|
+
.mb-gallery::before { content: ""; position: absolute; inset: 0; background: rgba(20,28,40,0.35); }
|
|
97
|
+
.mb-gallery-panel { position: relative; width: min(760px, 92%); max-height: 86%; background: var(--theme-color-background-panel, #fff); border: 1px solid var(--theme-color-border-default, #dfe3ea); border-radius: 12px; box-shadow: 0 18px 50px rgba(20,30,50,0.22); display: flex; flex-direction: column; overflow: hidden; }
|
|
98
|
+
.mb-gallery-head { display: flex; align-items: center; justify-content: space-between; padding: 13px 16px; border-bottom: 1px solid var(--theme-color-border-default, #eceff3); }
|
|
99
|
+
.mb-gallery-title { font-weight: 600; font-size: 15px; color: var(--theme-color-text-primary, #222); }
|
|
100
|
+
.mb-gallery-controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--theme-color-border-default, #eceff3); }
|
|
101
|
+
.mb-gallery-search { min-width: 200px; flex: 1; }
|
|
102
|
+
.mb-gallery-filter, .mb-gallery-sortlbl { font-size: 12px; color: var(--theme-color-text-secondary, #5b6376); display: inline-flex; gap: 5px; align-items: center; }
|
|
103
|
+
.mb-gallery-filter select, .mb-gallery-sort { padding: 5px 6px; border: 1px solid var(--theme-color-border-default, #ccc); border-radius: 6px; font-size: 13px; background: var(--theme-color-background-panel, #fff); }
|
|
104
|
+
.mb-gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; padding: 16px; overflow: auto; }
|
|
105
|
+
.mb-gallery-item { display: flex; flex-direction: column; padding: 0; border: 1px solid var(--theme-color-border-default, #e4e8ef); border-radius: 8px; background: var(--theme-color-background-secondary, #f7f8fb); cursor: pointer; overflow: hidden; text-align: left; }
|
|
106
|
+
.mb-gallery-item:hover { border-color: var(--theme-color-brand-primary, #2880a6); }
|
|
107
|
+
.mb-gallery-item img { width: 100%; height: 92px; object-fit: cover; display: block; background: #e9edf2; }
|
|
108
|
+
.mb-gallery-item-name { font-size: 11px; color: var(--theme-color-text-secondary, #5b6376); padding: 4px 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
109
|
+
.mb-gallery-empty { grid-column: 1 / -1; text-align: center; color: var(--theme-color-text-secondary, #8a93a5); padding: 44px 16px; }
|
|
110
|
+
`,
|
|
111
|
+
Templates:
|
|
112
|
+
[
|
|
113
|
+
{
|
|
114
|
+
Hash: 'Moodboard-Container',
|
|
115
|
+
Template: /*html*/`
|
|
116
|
+
<div class="mb-root" id="MB-Root-{~D:AppData.Moodboard.ViewID~}">
|
|
117
|
+
<div class="mb-toolbar" id="MB-Toolbar-{~D:AppData.Moodboard.ViewID~}"></div>
|
|
118
|
+
<div class="mb-canvas" id="MB-Canvas-{~D:AppData.Moodboard.ViewID~}"
|
|
119
|
+
ondblclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onCanvasDoubleClick(event)"
|
|
120
|
+
ondragover="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onDragOver(event)"
|
|
121
|
+
ondragleave="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onDragLeave(event)"
|
|
122
|
+
ondrop="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onDrop(event)">
|
|
123
|
+
<div class="mb-flow" id="MB-Flow-{~D:AppData.Moodboard.ViewID~}"></div>
|
|
124
|
+
</div>
|
|
125
|
+
<input type="file" accept="image/*" multiple style="display:none" id="MB-FileInput-{~D:AppData.Moodboard.ViewID~}"
|
|
126
|
+
onchange="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].addImageFiles(this.files); this.value='';">
|
|
127
|
+
<div class="mb-gallery" id="MB-Gallery-{~D:AppData.Moodboard.ViewID~}"></div>
|
|
128
|
+
</div>`
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
Hash: 'Moodboard-Toolbar',
|
|
132
|
+
Template: /*html*/`
|
|
133
|
+
<button class="mb-btn mb-btn-primary" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].addNote()">Add note</button>
|
|
134
|
+
<button class="mb-btn" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].addText()">Add text</button>
|
|
135
|
+
<div class="mb-swatches">{~TS:Moodboard-Swatch:AppData.Moodboard.NoteColors~}</div>
|
|
136
|
+
<div class="mb-sep"></div>
|
|
137
|
+
<button class="mb-btn" onclick="document.getElementById('MB-FileInput-{~D:AppData.Moodboard.ViewID~}').click()">Add image</button>
|
|
138
|
+
<input class="mb-url" placeholder="Paste image URL, press Enter" onkeydown="if(event.key==='Enter'){event.preventDefault(); _Pict.views['{~D:AppData.Moodboard.ViewID~}'].addImageFromInput(this);}">
|
|
139
|
+
<button class="mb-btn" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].openGallery()">Gallery</button>
|
|
140
|
+
<div class="mb-sep"></div>
|
|
141
|
+
<button class="mb-btn" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].duplicateSelected()">Duplicate</button>
|
|
142
|
+
<button class="mb-btn" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].toggleFit()">Fit</button>
|
|
143
|
+
<button class="mb-btn" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].bringToFront()">Bring to front</button>
|
|
144
|
+
<button class="mb-btn" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].deleteSelected()">Delete</button>
|
|
145
|
+
<span class="mb-hint">Double-click a card to edit. Drag the canvas to select (shift-click adds, shift-drag pans). Drop or paste images.</span>`
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
Hash: 'Moodboard-Swatch',
|
|
149
|
+
Template: /*html*/`<button class="mb-swatch" style="background:{~D:Record.Color~}" title="Add a {~D:Record.Color~} note" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].addNote('{~D:Record.Color~}')"></button>`
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
Hash: 'Moodboard-Gallery',
|
|
153
|
+
Template: /*html*/`
|
|
154
|
+
<div class="mb-gallery-panel">
|
|
155
|
+
<div class="mb-gallery-head">
|
|
156
|
+
<span class="mb-gallery-title">Image gallery</span>
|
|
157
|
+
<button class="mb-btn" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].closeGallery()">Close</button>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="mb-gallery-controls">
|
|
160
|
+
<input class="mb-url mb-gallery-search" placeholder="Search images" oninput="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onGallerySearch(this.value)">
|
|
161
|
+
{~TS:Moodboard-Gallery-Filter:AppData.Moodboard.Gallery.FilterFields~}
|
|
162
|
+
<label class="mb-gallery-sortlbl">Sort
|
|
163
|
+
<select class="mb-gallery-sort" onchange="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onGallerySort(this.value)"><option value="">none</option>{~TS:Moodboard-Gallery-Sort-Option:AppData.Moodboard.Gallery.SortFields~}</select>
|
|
164
|
+
</label>
|
|
165
|
+
<button class="mb-btn mb-gallery-dir" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onGallerySortDir()">{~D:AppData.Moodboard.Gallery.SortDirLabel~}</button>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="mb-gallery-grid" id="MB-Gallery-Grid-{~D:AppData.Moodboard.ViewID~}"></div>
|
|
168
|
+
</div>`
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
Hash: 'Moodboard-Gallery-Filter',
|
|
172
|
+
Template: /*html*/`<label class="mb-gallery-filter">{~D:Record.Label~}<select onchange="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].onGalleryFilter('{~D:Record.Key~}', this.value)"><option value="">All</option>{~TS:Moodboard-Gallery-Filter-Option:Record.Options~}</select></label>`
|
|
173
|
+
},
|
|
174
|
+
{ Hash: 'Moodboard-Gallery-Filter-Option', Template: /*html*/`<option value="{~D:Record.Value~}">{~D:Record.Value~}</option>` },
|
|
175
|
+
{ Hash: 'Moodboard-Gallery-Sort-Option', Template: /*html*/`<option value="{~D:Record.Key~}">{~D:Record.Label~}</option>` },
|
|
176
|
+
{
|
|
177
|
+
Hash: 'Moodboard-Gallery-Grid',
|
|
178
|
+
Template: /*html*/`{~TS:Moodboard-Gallery-Item:AppData.Moodboard.Gallery.Items~}{~TS:Moodboard-Gallery-Empty:AppData.Moodboard.Gallery.EmptySlot~}`
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
Hash: 'Moodboard-Gallery-Item',
|
|
182
|
+
Template: /*html*/`<button class="mb-gallery-item" title="{~D:Record.Name~}" onclick="_Pict.views['{~D:AppData.Moodboard.ViewID~}'].pickFromGallery('{~D:Record.Id~}')"><img src="{~D:Record.Thumbnail~}" alt="" draggable="false"><span class="mb-gallery-item-name">{~D:Record.Name~}</span></button>`
|
|
183
|
+
},
|
|
184
|
+
{ Hash: 'Moodboard-Gallery-Empty', Template: /*html*/`<div class="mb-gallery-empty">No images here yet. Add some with the toolbar (URL, file, drop, or paste) and they show up in the gallery to reuse.</div>` }
|
|
185
|
+
],
|
|
186
|
+
Renderables:
|
|
187
|
+
[
|
|
188
|
+
{ RenderableHash: 'Moodboard-Container', TemplateHash: 'Moodboard-Container', ContentDestinationAddress: '#Moodboard-Container', RenderMethod: 'replace' }
|
|
189
|
+
]
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
class PictViewMoodboard extends libPictView
|
|
193
|
+
{
|
|
194
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
195
|
+
{
|
|
196
|
+
super(pFable, pOptions, pServiceHash);
|
|
197
|
+
this._FlowView = null;
|
|
198
|
+
this._AddCount = 0;
|
|
199
|
+
// An embedding app can supply an image source (its own gallery + metadata); otherwise the
|
|
200
|
+
// built-in base source keeps a base64 collection so the gallery works stand-alone.
|
|
201
|
+
this._ImageSource = (pOptions && pOptions.ImageSource) ? pOptions.ImageSource : new libImageSource();
|
|
202
|
+
this._boundOnPaste = this._onPaste.bind(this);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
onBeforeInitialize()
|
|
206
|
+
{
|
|
207
|
+
this._initState();
|
|
208
|
+
return super.onBeforeInitialize();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_initState()
|
|
212
|
+
{
|
|
213
|
+
if (!this.pict.AppData.Moodboard)
|
|
214
|
+
{
|
|
215
|
+
this.pict.AppData.Moodboard =
|
|
216
|
+
{
|
|
217
|
+
ViewID: this.options.ViewIdentifier,
|
|
218
|
+
NoteColors: _NOTE_COLORS.map((pColor) => ({ Color: pColor }))
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
this.pict.AppData.Moodboard.ViewID = this.options.ViewIdentifier;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent)
|
|
225
|
+
{
|
|
226
|
+
this._ensureFlowView();
|
|
227
|
+
this._renderToolbar();
|
|
228
|
+
// Clipboard paste of an image is a window-level event with no inline equivalent; wire once.
|
|
229
|
+
if (!this._PasteWired)
|
|
230
|
+
{
|
|
231
|
+
document.addEventListener('paste', this._boundOnPaste);
|
|
232
|
+
this._PasteWired = true;
|
|
233
|
+
}
|
|
234
|
+
this.pict.CSSMap.injectCSS();
|
|
235
|
+
return super.onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
_renderToolbar() { this.pict.ContentAssignment.assignContent('#MB-Toolbar-' + this.options.ViewIdentifier, this.pict.parseTemplateByHash('Moodboard-Toolbar', { ViewID: this.options.ViewIdentifier })); }
|
|
239
|
+
|
|
240
|
+
_ensureFlowView()
|
|
241
|
+
{
|
|
242
|
+
let tmpID = this.options.ViewIdentifier;
|
|
243
|
+
let tmpContainer = '#MB-Flow-' + tmpID;
|
|
244
|
+
if (!this._FlowView)
|
|
245
|
+
{
|
|
246
|
+
let tmpNodeTypes = {};
|
|
247
|
+
[ new libMoodImageCard(this.fable, {}, 'Moodboard-ImageCard'), new libMoodNoteCard(this.fable, {}, 'Moodboard-NoteCard'), new libMoodTextCard(this.fable, {}, 'Moodboard-TextCard') ].forEach((pCard) =>
|
|
248
|
+
{
|
|
249
|
+
let tmpConfig = pCard.getNodeTypeConfiguration();
|
|
250
|
+
tmpNodeTypes[tmpConfig.Hash] = tmpConfig;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this._FlowView = this.pict.addView('MB-FlowView-' + tmpID,
|
|
254
|
+
{
|
|
255
|
+
ViewIdentifier: 'MB-FlowView-' + tmpID,
|
|
256
|
+
DefaultRenderable: 'Flow-Container',
|
|
257
|
+
DefaultDestinationAddress: tmpContainer,
|
|
258
|
+
AutoRender: false,
|
|
259
|
+
EnableToolbar: false,
|
|
260
|
+
EnableCardPalette: false,
|
|
261
|
+
EnableAddNode: false,
|
|
262
|
+
EnableLayoutMenu: false,
|
|
263
|
+
IncludeDefaultNodeTypes: false,
|
|
264
|
+
EnableConnectionCreation: false,
|
|
265
|
+
EnableNodeDragging: true,
|
|
266
|
+
EnableNodeResizing: true,
|
|
267
|
+
EnablePanning: true,
|
|
268
|
+
EnableZooming: true,
|
|
269
|
+
EnableGridSnap: true,
|
|
270
|
+
GridSnapSize: 10,
|
|
271
|
+
EnableMultiSelect: true,
|
|
272
|
+
EnableAlignmentGuides: true,
|
|
273
|
+
NodeTitleBarHeight: 0,
|
|
274
|
+
DefaultNodeType: 'MoodNote',
|
|
275
|
+
NodeTypes: tmpNodeTypes,
|
|
276
|
+
Renderables: [ { RenderableHash: 'Flow-Container', TemplateHash: 'Flow-Container-Template', DestinationAddress: tmpContainer, RenderMethod: 'replace' } ]
|
|
277
|
+
},
|
|
278
|
+
libPictSectionFlow);
|
|
279
|
+
}
|
|
280
|
+
this._FlowView.initialRenderComplete = false;
|
|
281
|
+
this._FlowView.render();
|
|
282
|
+
|
|
283
|
+
// Keep the board to a single open editor (see _keepOnlyPanel). Wire once, after the flow's
|
|
284
|
+
// services exist.
|
|
285
|
+
if (!this._PanelHandlerWired && this._FlowView._EventHandlerProvider)
|
|
286
|
+
{
|
|
287
|
+
this._FlowView._EventHandlerProvider.registerHandler('onPanelOpened', (pPanelData) => this._keepOnlyPanel(pPanelData ? pPanelData.Hash : null));
|
|
288
|
+
// Structural changes (drag, resize, add, delete) flow through onFlowChanged; re-emit them so a
|
|
289
|
+
// host can autosave. Panel/toolbar edits that bypass onFlowChanged call _emitChange directly.
|
|
290
|
+
this._FlowView._EventHandlerProvider.registerHandler('onFlowChanged', () => this._emitChange());
|
|
291
|
+
this._PanelHandlerWired = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Notify a host (options.onBoardChanged) that the board changed, handing it the current board so it
|
|
296
|
+
// can persist. Hosts should debounce; this can fire rapidly during a drag.
|
|
297
|
+
_emitChange()
|
|
298
|
+
{
|
|
299
|
+
if (typeof this.options.onBoardChanged === 'function')
|
|
300
|
+
{
|
|
301
|
+
this.options.onBoardChanged(this.getBoard());
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---- Adding cards ----
|
|
306
|
+
|
|
307
|
+
_nextPosition()
|
|
308
|
+
{
|
|
309
|
+
// Cascade new cards from the upper-left of the visible canvas so several adds do not stack.
|
|
310
|
+
let tmpStep = (this._AddCount % 8) * 26;
|
|
311
|
+
this._AddCount++;
|
|
312
|
+
let tmpVS = this._FlowView ? this._FlowView.viewState : { PanX: 0, PanY: 0, Zoom: 1 };
|
|
313
|
+
return { x: (60 - tmpVS.PanX) / tmpVS.Zoom + tmpStep, y: (60 - tmpVS.PanY) / tmpVS.Zoom + tmpStep };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A moodboard edits one card at a time. The flow lets several panels stack up (open one per
|
|
317
|
+
// double-click); here we keep only the most recently opened so the board does not fill with
|
|
318
|
+
// editors. Driven by the flow's onPanelOpened event so it covers both adds and double-clicks.
|
|
319
|
+
_keepOnlyPanel(pKeepHash)
|
|
320
|
+
{
|
|
321
|
+
if (this._PanelGuard || !this._FlowView || !this._FlowView.flowData) return;
|
|
322
|
+
this._PanelGuard = true;
|
|
323
|
+
let tmpPanels = (this._FlowView.flowData.OpenPanels || []).slice();
|
|
324
|
+
tmpPanels.forEach((pPanel) => { if (pPanel.Hash !== pKeepHash) { this._FlowView.closePanel(pPanel.Hash); } });
|
|
325
|
+
this._PanelGuard = false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
addNote(pColor)
|
|
329
|
+
{
|
|
330
|
+
let tmpPos = this._nextPosition();
|
|
331
|
+
this._addNoteAt(tmpPos.x, tmpPos.y, pColor);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_addNoteAt(pX, pY, pColor)
|
|
335
|
+
{
|
|
336
|
+
if (!this._FlowView) return;
|
|
337
|
+
let tmpColor = (typeof pColor === 'string' && pColor) ? pColor : _NOTE_COLORS[0];
|
|
338
|
+
let tmpNode = this._FlowView.addNode('MoodNote', pX, pY, '', { Text: '', Color: tmpColor });
|
|
339
|
+
if (tmpNode)
|
|
340
|
+
{
|
|
341
|
+
tmpNode.Ports = []; // addNode defaults to one In + one Out; a moodboard card has neither
|
|
342
|
+
tmpNode.Data.Color = tmpColor;
|
|
343
|
+
tmpNode.Style = { BodyFill: tmpColor, TitleBarColor: tmpColor };
|
|
344
|
+
this._FlowView.selectNode(tmpNode.Hash);
|
|
345
|
+
this._FlowView.renderFlow();
|
|
346
|
+
this._FlowView.marshalFromView();
|
|
347
|
+
this._FlowView.openPanel(tmpNode.Hash); // drop straight into the editor so the user can type
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
addText()
|
|
352
|
+
{
|
|
353
|
+
let tmpPos = this._nextPosition();
|
|
354
|
+
this._addTextAt(tmpPos.x, tmpPos.y);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
_addTextAt(pX, pY)
|
|
358
|
+
{
|
|
359
|
+
if (!this._FlowView) return;
|
|
360
|
+
let tmpNode = this._FlowView.addNode('MoodText', pX, pY, '', { Text: '' });
|
|
361
|
+
if (tmpNode)
|
|
362
|
+
{
|
|
363
|
+
tmpNode.Ports = [];
|
|
364
|
+
this._FlowView.selectNode(tmpNode.Hash);
|
|
365
|
+
this._FlowView.renderFlow();
|
|
366
|
+
this._FlowView.marshalFromView();
|
|
367
|
+
this._FlowView.openPanel(tmpNode.Hash); // drop straight into the editor so the user can type
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
onCanvasDoubleClick(pEvent)
|
|
372
|
+
{
|
|
373
|
+
// A double-click on a card is handled by the flow itself (it opens that card's properties
|
|
374
|
+
// panel). Here we only handle the empty canvas: drop a note right where the user clicked.
|
|
375
|
+
let tmpCard = (pEvent.target && pEvent.target.closest) ? pEvent.target.closest('.pict-flow-node') : null;
|
|
376
|
+
if (tmpCard) { return; }
|
|
377
|
+
if (!this._FlowView || typeof this._FlowView.screenToSVGCoords !== 'function') { return; }
|
|
378
|
+
let tmpCoords = this._FlowView.screenToSVGCoords(pEvent.clientX, pEvent.clientY);
|
|
379
|
+
this._addNoteAt(tmpCoords.x - 100, tmpCoords.y - 70, _NOTE_COLORS[0]);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
duplicateSelected()
|
|
383
|
+
{
|
|
384
|
+
if (!this._FlowView) return;
|
|
385
|
+
let tmpHashes = this._selectedHashes();
|
|
386
|
+
if (tmpHashes.length === 0) return;
|
|
387
|
+
let tmpClones = [];
|
|
388
|
+
tmpHashes.forEach((pHash) =>
|
|
389
|
+
{
|
|
390
|
+
let tmpNode = this._FlowView.getNode(pHash);
|
|
391
|
+
if (!tmpNode) return;
|
|
392
|
+
let tmpClone = JSON.parse(JSON.stringify(tmpNode));
|
|
393
|
+
tmpClone.Hash = 'node-' + this.fable.getUUID();
|
|
394
|
+
tmpClone.X = (tmpNode.X || 0) + 24;
|
|
395
|
+
tmpClone.Y = (tmpNode.Y || 0) + 24;
|
|
396
|
+
tmpClone.Ports = [];
|
|
397
|
+
this._FlowView.flowData.Nodes.push(tmpClone);
|
|
398
|
+
tmpClones.push(tmpClone.Hash);
|
|
399
|
+
});
|
|
400
|
+
// Select the fresh copies so the next drag moves them, not the originals.
|
|
401
|
+
this._FlowView.selectNodes(tmpClones);
|
|
402
|
+
this._FlowView.renderFlow();
|
|
403
|
+
this._FlowView.marshalFromView();
|
|
404
|
+
this._emitChange();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// The current selection as an array of node hashes (the flow's set, or the single primary).
|
|
408
|
+
_selectedHashes()
|
|
409
|
+
{
|
|
410
|
+
if (this._FlowView && typeof this._FlowView.getSelectedNodeHashes === 'function')
|
|
411
|
+
{
|
|
412
|
+
let tmpSet = this._FlowView.getSelectedNodeHashes();
|
|
413
|
+
if (tmpSet && tmpSet.length) { return tmpSet; }
|
|
414
|
+
}
|
|
415
|
+
let tmpPrimary = (this._FlowView && this._FlowView.viewState) ? this._FlowView.viewState.SelectedNodeHash : null;
|
|
416
|
+
return tmpPrimary ? [tmpPrimary] : [];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
toggleFit()
|
|
420
|
+
{
|
|
421
|
+
if (!this._FlowView) return;
|
|
422
|
+
let tmpHash = this._FlowView.viewState ? this._FlowView.viewState.SelectedNodeHash : null;
|
|
423
|
+
if (!tmpHash) return;
|
|
424
|
+
let tmpNode = this._FlowView.getNode(tmpHash);
|
|
425
|
+
if (!tmpNode || tmpNode.Type !== 'MoodImage') return;
|
|
426
|
+
if (!tmpNode.Data) tmpNode.Data = {};
|
|
427
|
+
tmpNode.Data.Fit = (tmpNode.Data.Fit === 'contain') ? 'cover' : 'contain';
|
|
428
|
+
this._FlowView.renderFlow();
|
|
429
|
+
this._FlowView.marshalFromView();
|
|
430
|
+
this._emitChange();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
addImage(pUrl, pMeta)
|
|
434
|
+
{
|
|
435
|
+
if (!this._FlowView || !pUrl) return;
|
|
436
|
+
let tmpMeta = pMeta || {};
|
|
437
|
+
// Register the image with the source so the gallery can show and reuse it (deduped by URL).
|
|
438
|
+
if (this._ImageSource && typeof this._ImageSource.add === 'function')
|
|
439
|
+
{
|
|
440
|
+
this._ImageSource.add(
|
|
441
|
+
{
|
|
442
|
+
Url: pUrl,
|
|
443
|
+
Name: tmpMeta.Name || this._urlName(pUrl),
|
|
444
|
+
Metadata: { Type: tmpMeta.Type || 'image', SizeBytes: tmpMeta.SizeBytes || 0, AddedAt: tmpMeta.AddedAt || Date.now() }
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
let tmpPos = this._nextPosition();
|
|
448
|
+
let tmpNode = this._FlowView.addNode('MoodImage', tmpPos.x, tmpPos.y, '', { ImageUrl: pUrl, Fit: 'cover' });
|
|
449
|
+
if (tmpNode)
|
|
450
|
+
{
|
|
451
|
+
tmpNode.Ports = []; // addNode defaults to one In + one Out; a moodboard card has neither
|
|
452
|
+
this._FlowView.selectNode(tmpNode.Hash);
|
|
453
|
+
this._FlowView.renderFlow();
|
|
454
|
+
this._FlowView.marshalFromView();
|
|
455
|
+
}
|
|
456
|
+
if (this._galleryState().Open) { this._refreshGalleryGrid(); }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
_urlName(pUrl)
|
|
460
|
+
{
|
|
461
|
+
if (!pUrl) return 'image';
|
|
462
|
+
if (pUrl.indexOf('data:') === 0) return 'pasted image';
|
|
463
|
+
let tmpClean = pUrl.split('?')[0].split('#')[0];
|
|
464
|
+
let tmpName = tmpClean.substring(tmpClean.lastIndexOf('/') + 1);
|
|
465
|
+
return tmpName || pUrl;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
addImageFromInput(pInputEl)
|
|
469
|
+
{
|
|
470
|
+
if (!pInputEl) return;
|
|
471
|
+
let tmpUrl = (pInputEl.value || '').trim();
|
|
472
|
+
if (!tmpUrl) return;
|
|
473
|
+
this.addImage(tmpUrl);
|
|
474
|
+
pInputEl.value = '';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
addImageFiles(pFileList)
|
|
478
|
+
{
|
|
479
|
+
if (!pFileList) return;
|
|
480
|
+
let tmpFiles = Array.prototype.slice.call(pFileList).filter((pFile) => pFile && pFile.type && pFile.type.indexOf('image/') === 0);
|
|
481
|
+
tmpFiles.forEach((pFile) => this._readFileAsImage(pFile));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
_readFileAsImage(pFile)
|
|
485
|
+
{
|
|
486
|
+
let tmpSelf = this;
|
|
487
|
+
let tmpReader = new FileReader();
|
|
488
|
+
tmpReader.onload = function (pEvent)
|
|
489
|
+
{
|
|
490
|
+
let tmpMeta = { Name: pFile.name, Type: pFile.type || 'image', SizeBytes: pFile.size || 0, AddedAt: Date.now() };
|
|
491
|
+
// A host with an upload hook stores the bytes and hands back a reference; otherwise the
|
|
492
|
+
// base64 data URL goes straight onto the board (and into the built-in source).
|
|
493
|
+
if (tmpSelf._ImageSource && typeof tmpSelf._ImageSource.upload === 'function')
|
|
494
|
+
{
|
|
495
|
+
tmpSelf._ImageSource.upload(pFile, pEvent.target.result, function (pErr, pRef)
|
|
496
|
+
{
|
|
497
|
+
tmpSelf.addImage((pRef && pRef.Url) ? pRef.Url : pEvent.target.result, tmpMeta);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
else
|
|
501
|
+
{
|
|
502
|
+
tmpSelf.addImage(pEvent.target.result, tmpMeta);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
tmpReader.readAsDataURL(pFile);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---- Editing (called from each card's on-graph properties panel) ----
|
|
509
|
+
// The panels carry the real editor (a textarea plus parameters: font size, note color, image fit
|
|
510
|
+
// and URL). The card body is a read-only display. These setters write the node data and update the
|
|
511
|
+
// already-rendered card in place. They deliberately do NOT call renderFlow, because a full re-render
|
|
512
|
+
// would also re-render the open panel and drop the textarea's focus mid-keystroke; marshalFromView
|
|
513
|
+
// keeps the persisted board in sync without touching the DOM the user is typing into.
|
|
514
|
+
|
|
515
|
+
_cardElement(pNodeHash, pSelector)
|
|
516
|
+
{
|
|
517
|
+
let tmpGroup = document.querySelector('#MB-Flow-' + this.options.ViewIdentifier + ' [data-node-hash="' + pNodeHash + '"]');
|
|
518
|
+
return tmpGroup ? tmpGroup.querySelector(pSelector) : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
editText(pNodeHash, pText)
|
|
522
|
+
{
|
|
523
|
+
if (!this._FlowView) return;
|
|
524
|
+
let tmpNode = this._FlowView.getNode(pNodeHash);
|
|
525
|
+
if (!tmpNode) return;
|
|
526
|
+
if (!tmpNode.Data) tmpNode.Data = {};
|
|
527
|
+
tmpNode.Data.Text = pText;
|
|
528
|
+
let tmpField = this._cardElement(pNodeHash, '.mb-note, .mb-text');
|
|
529
|
+
if (tmpField) { tmpField.textContent = pText; }
|
|
530
|
+
this._FlowView.marshalFromView();
|
|
531
|
+
this._emitChange();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
setFontSize(pNodeHash, pValue)
|
|
535
|
+
{
|
|
536
|
+
if (!this._FlowView) return;
|
|
537
|
+
let tmpNode = this._FlowView.getNode(pNodeHash);
|
|
538
|
+
if (!tmpNode) return;
|
|
539
|
+
if (!tmpNode.Data) tmpNode.Data = {};
|
|
540
|
+
let tmpNum = parseInt(pValue, 10);
|
|
541
|
+
if (pValue && !isNaN(tmpNum) && tmpNum > 0)
|
|
542
|
+
{
|
|
543
|
+
tmpNode.Data.FontSize = tmpNum;
|
|
544
|
+
tmpNode.Data.FontSizeCss = tmpNum + 'px';
|
|
545
|
+
}
|
|
546
|
+
else
|
|
547
|
+
{
|
|
548
|
+
// Empty / "Auto" clears the fixed size so the type scales with the card box again.
|
|
549
|
+
tmpNode.Data.FontSize = null;
|
|
550
|
+
tmpNode.Data.FontSizeCss = '';
|
|
551
|
+
}
|
|
552
|
+
let tmpField = this._cardElement(pNodeHash, '.mb-text');
|
|
553
|
+
if (tmpField) { tmpField.style.fontSize = tmpNode.Data.FontSizeCss; }
|
|
554
|
+
this._FlowView.marshalFromView();
|
|
555
|
+
this._emitChange();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
setNoteColor(pNodeHash, pColor)
|
|
559
|
+
{
|
|
560
|
+
if (!this._FlowView || !pColor) return;
|
|
561
|
+
let tmpNode = this._FlowView.getNode(pNodeHash);
|
|
562
|
+
if (!tmpNode) return;
|
|
563
|
+
if (!tmpNode.Data) tmpNode.Data = {};
|
|
564
|
+
tmpNode.Data.Color = pColor;
|
|
565
|
+
tmpNode.Style = { BodyFill: pColor, TitleBarColor: pColor };
|
|
566
|
+
let tmpBody = this._cardElement(pNodeHash, '.pict-flow-node-body');
|
|
567
|
+
if (tmpBody) { tmpBody.style.fill = pColor; }
|
|
568
|
+
this._FlowView.marshalFromView();
|
|
569
|
+
this._emitChange();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
setFit(pNodeHash, pFit)
|
|
573
|
+
{
|
|
574
|
+
if (!this._FlowView) return;
|
|
575
|
+
let tmpNode = this._FlowView.getNode(pNodeHash);
|
|
576
|
+
if (!tmpNode) return;
|
|
577
|
+
if (!tmpNode.Data) tmpNode.Data = {};
|
|
578
|
+
tmpNode.Data.Fit = (pFit === 'contain') ? 'contain' : 'cover';
|
|
579
|
+
let tmpImg = this._cardElement(pNodeHash, '.mb-image');
|
|
580
|
+
if (tmpImg) { tmpImg.className = 'mb-image mb-image-' + tmpNode.Data.Fit; }
|
|
581
|
+
this._FlowView.marshalFromView();
|
|
582
|
+
this._emitChange();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
setImageUrl(pNodeHash, pUrl)
|
|
586
|
+
{
|
|
587
|
+
if (!this._FlowView) return;
|
|
588
|
+
let tmpNode = this._FlowView.getNode(pNodeHash);
|
|
589
|
+
if (!tmpNode) return;
|
|
590
|
+
if (!tmpNode.Data) tmpNode.Data = {};
|
|
591
|
+
tmpNode.Data.ImageUrl = pUrl;
|
|
592
|
+
let tmpImg = this._cardElement(pNodeHash, '.mb-image');
|
|
593
|
+
if (tmpImg) { tmpImg.setAttribute('src', pUrl || ''); }
|
|
594
|
+
this._FlowView.marshalFromView();
|
|
595
|
+
this._emitChange();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Rotation is a node-level property (the flow renderer rotates the whole card group about its
|
|
599
|
+
// center). Update the group's transform in place so the slider feels live without a re-render.
|
|
600
|
+
setRotation(pNodeHash, pValue)
|
|
601
|
+
{
|
|
602
|
+
if (!this._FlowView) return;
|
|
603
|
+
let tmpNode = this._FlowView.getNode(pNodeHash);
|
|
604
|
+
if (!tmpNode) return;
|
|
605
|
+
let tmpDeg = parseInt(pValue, 10);
|
|
606
|
+
tmpNode.Rotation = isNaN(tmpDeg) ? 0 : tmpDeg;
|
|
607
|
+
let tmpGroup = document.querySelector('#MB-Flow-' + this.options.ViewIdentifier + ' [data-node-hash="' + pNodeHash + '"]');
|
|
608
|
+
if (tmpGroup)
|
|
609
|
+
{
|
|
610
|
+
let tmpW = (typeof tmpNode.Width === 'number') ? tmpNode.Width : 200;
|
|
611
|
+
let tmpH = (typeof tmpNode.Height === 'number') ? tmpNode.Height : 160;
|
|
612
|
+
let tmpTransform = 'translate(' + tmpNode.X + ', ' + tmpNode.Y + ')';
|
|
613
|
+
if (tmpNode.Rotation) { tmpTransform += ' rotate(' + tmpNode.Rotation + ' ' + (tmpW / 2) + ' ' + (tmpH / 2) + ')'; }
|
|
614
|
+
tmpGroup.setAttribute('transform', tmpTransform);
|
|
615
|
+
}
|
|
616
|
+
this._FlowView.marshalFromView();
|
|
617
|
+
this._emitChange();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
deleteSelected()
|
|
621
|
+
{
|
|
622
|
+
if (this._FlowView) { this._FlowView.deleteSelected(); }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
bringToFront()
|
|
626
|
+
{
|
|
627
|
+
if (!this._FlowView) return;
|
|
628
|
+
let tmpHashes = this._selectedHashes();
|
|
629
|
+
if (tmpHashes.length === 0) return;
|
|
630
|
+
let tmpNodes = this._FlowView.flowData.Nodes || [];
|
|
631
|
+
// Pull the selected nodes out (keeping their relative order) and re-append them on top.
|
|
632
|
+
let tmpMoved = [];
|
|
633
|
+
for (let i = tmpNodes.length - 1; i >= 0; i--)
|
|
634
|
+
{
|
|
635
|
+
if (tmpHashes.indexOf(tmpNodes[i].Hash) >= 0) { tmpMoved.unshift(tmpNodes.splice(i, 1)[0]); }
|
|
636
|
+
}
|
|
637
|
+
if (tmpMoved.length === 0) return;
|
|
638
|
+
tmpMoved.forEach((pNode) => tmpNodes.push(pNode));
|
|
639
|
+
this._FlowView.renderFlow();
|
|
640
|
+
this._FlowView.marshalFromView();
|
|
641
|
+
this._emitChange();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---- Drag-and-drop + paste ----
|
|
645
|
+
|
|
646
|
+
onDragOver(pEvent)
|
|
647
|
+
{
|
|
648
|
+
pEvent.preventDefault();
|
|
649
|
+
let tmpCanvas = document.getElementById('MB-Canvas-' + this.options.ViewIdentifier);
|
|
650
|
+
if (tmpCanvas) tmpCanvas.classList.add('mb-dropping');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
onDragLeave(pEvent)
|
|
654
|
+
{
|
|
655
|
+
let tmpCanvas = document.getElementById('MB-Canvas-' + this.options.ViewIdentifier);
|
|
656
|
+
if (tmpCanvas) tmpCanvas.classList.remove('mb-dropping');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
onDrop(pEvent)
|
|
660
|
+
{
|
|
661
|
+
pEvent.preventDefault();
|
|
662
|
+
let tmpCanvas = document.getElementById('MB-Canvas-' + this.options.ViewIdentifier);
|
|
663
|
+
if (tmpCanvas) tmpCanvas.classList.remove('mb-dropping');
|
|
664
|
+
|
|
665
|
+
let tmpData = pEvent.dataTransfer;
|
|
666
|
+
if (!tmpData) return;
|
|
667
|
+
if (tmpData.files && tmpData.files.length > 0)
|
|
668
|
+
{
|
|
669
|
+
this.addImageFiles(tmpData.files);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// A dragged image or link from another page arrives as a URL.
|
|
673
|
+
let tmpUrl = tmpData.getData('text/uri-list') || tmpData.getData('text/plain');
|
|
674
|
+
if (tmpUrl && /^https?:\/\//i.test(tmpUrl.trim())) { this.addImage(tmpUrl.trim()); }
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
_onPaste(pEvent)
|
|
678
|
+
{
|
|
679
|
+
// Only act when this moodboard is on screen.
|
|
680
|
+
if (!document.getElementById('MB-Root-' + this.options.ViewIdentifier)) return;
|
|
681
|
+
let tmpItems = (pEvent.clipboardData && pEvent.clipboardData.items) ? pEvent.clipboardData.items : null;
|
|
682
|
+
if (!tmpItems) return;
|
|
683
|
+
for (let i = 0; i < tmpItems.length; i++)
|
|
684
|
+
{
|
|
685
|
+
let tmpItem = tmpItems[i];
|
|
686
|
+
if (tmpItem.type && tmpItem.type.indexOf('image/') === 0)
|
|
687
|
+
{
|
|
688
|
+
let tmpFile = tmpItem.getAsFile();
|
|
689
|
+
if (tmpFile) { this._readFileAsImage(tmpFile); pEvent.preventDefault(); }
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ---- Gallery ----
|
|
695
|
+
// The picker builds itself from whatever fields the image source declares (getFields), so a host
|
|
696
|
+
// source with custom metadata gets custom filter/sort controls for free. Search is re-applied to
|
|
697
|
+
// the grid only (the controls are not re-rendered) so the search box keeps focus while typing.
|
|
698
|
+
|
|
699
|
+
_galleryState()
|
|
700
|
+
{
|
|
701
|
+
let tmpMb = this.pict.AppData.Moodboard;
|
|
702
|
+
if (!tmpMb.Gallery)
|
|
703
|
+
{
|
|
704
|
+
tmpMb.Gallery = { Open: false, Query: { Search: '', Filters: {}, Sort: { Field: null, Direction: 'asc' } }, SortDirLabel: 'Asc', FilterFields: [], SortFields: [], Items: [], EmptySlot: [{}] };
|
|
705
|
+
}
|
|
706
|
+
return tmpMb.Gallery;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
openGallery()
|
|
710
|
+
{
|
|
711
|
+
let tmpGallery = this._galleryState();
|
|
712
|
+
tmpGallery.Open = true;
|
|
713
|
+
|
|
714
|
+
let tmpSource = this._ImageSource;
|
|
715
|
+
let tmpFields = (tmpSource && tmpSource.getFields) ? tmpSource.getFields() : [];
|
|
716
|
+
tmpGallery.FilterFields = tmpFields.filter((pField) => pField.Filterable).map((pField) =>
|
|
717
|
+
({ Key: pField.Key, Label: pField.Label, Options: (tmpSource.getFilterOptions ? tmpSource.getFilterOptions(pField.Key) : []).map((pValue) => ({ Value: pValue })) }));
|
|
718
|
+
tmpGallery.SortFields = tmpFields.filter((pField) => pField.Sortable).map((pField) => ({ Key: pField.Key, Label: pField.Label }));
|
|
719
|
+
|
|
720
|
+
this.pict.ContentAssignment.assignContent('#MB-Gallery-' + this.options.ViewIdentifier, this.pict.parseTemplateByHash('Moodboard-Gallery', { ViewID: this.options.ViewIdentifier }));
|
|
721
|
+
let tmpRoot = document.getElementById('MB-Root-' + this.options.ViewIdentifier);
|
|
722
|
+
if (tmpRoot) { tmpRoot.classList.add('mb-gallery-open'); }
|
|
723
|
+
this._refreshGalleryGrid();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
closeGallery()
|
|
727
|
+
{
|
|
728
|
+
this._galleryState().Open = false;
|
|
729
|
+
let tmpRoot = document.getElementById('MB-Root-' + this.options.ViewIdentifier);
|
|
730
|
+
if (tmpRoot) { tmpRoot.classList.remove('mb-gallery-open'); }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
_refreshGalleryGrid()
|
|
734
|
+
{
|
|
735
|
+
let tmpGallery = this._galleryState();
|
|
736
|
+
// list() may return an array (the built-in base source) or a Promise (a host source backed by a
|
|
737
|
+
// remote store); Promise.resolve handles both, so a remote gallery renders the same way.
|
|
738
|
+
let tmpResult = (this._ImageSource && this._ImageSource.list) ? this._ImageSource.list(tmpGallery.Query) : [];
|
|
739
|
+
Promise.resolve(tmpResult).then((pItems) =>
|
|
740
|
+
{
|
|
741
|
+
let tmpItems = Array.isArray(pItems) ? pItems : [];
|
|
742
|
+
tmpGallery.Items = tmpItems;
|
|
743
|
+
tmpGallery.EmptySlot = tmpItems.length ? [] : [{}];
|
|
744
|
+
this.pict.ContentAssignment.assignContent('#MB-Gallery-Grid-' + this.options.ViewIdentifier, this.pict.parseTemplateByHash('Moodboard-Gallery-Grid', { ViewID: this.options.ViewIdentifier }));
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
onGallerySearch(pValue) { this._galleryState().Query.Search = pValue || ''; this._refreshGalleryGrid(); }
|
|
749
|
+
onGalleryFilter(pKey, pValue) { this._galleryState().Query.Filters[pKey] = pValue; this._refreshGalleryGrid(); }
|
|
750
|
+
onGallerySort(pField) { this._galleryState().Query.Sort.Field = pField || null; this._refreshGalleryGrid(); }
|
|
751
|
+
|
|
752
|
+
onGallerySortDir()
|
|
753
|
+
{
|
|
754
|
+
let tmpGallery = this._galleryState();
|
|
755
|
+
tmpGallery.Query.Sort.Direction = (tmpGallery.Query.Sort.Direction === 'asc') ? 'desc' : 'asc';
|
|
756
|
+
tmpGallery.SortDirLabel = (tmpGallery.Query.Sort.Direction === 'asc') ? 'Asc' : 'Desc';
|
|
757
|
+
let tmpBtn = document.querySelector('#MB-Gallery-' + this.options.ViewIdentifier + ' .mb-gallery-dir');
|
|
758
|
+
if (tmpBtn) { tmpBtn.textContent = tmpGallery.SortDirLabel; }
|
|
759
|
+
this._refreshGalleryGrid();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
pickFromGallery(pId)
|
|
763
|
+
{
|
|
764
|
+
let tmpItems = this._galleryState().Items || [];
|
|
765
|
+
let tmpItem = tmpItems.find((pItem) => pItem.Id === pId);
|
|
766
|
+
if (tmpItem) { this.addImage(tmpItem.Url, Object.assign({ Name: tmpItem.Name }, tmpItem.Metadata)); }
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ---- Persistence ----
|
|
770
|
+
|
|
771
|
+
getBoard()
|
|
772
|
+
{
|
|
773
|
+
if (!this._FlowView) { return { Nodes: [], Connections: [], ViewState: { PanX: 0, PanY: 0, Zoom: 1 } }; }
|
|
774
|
+
let tmpBoard = this._FlowView.getFlowData();
|
|
775
|
+
// A saved board is content (cards + viewport), not transient editor state: drop any open
|
|
776
|
+
// properties panels so reopening a board does not restore a clutter of editors.
|
|
777
|
+
if (tmpBoard && tmpBoard.OpenPanels) { tmpBoard = Object.assign({}, tmpBoard, { OpenPanels: [] }); }
|
|
778
|
+
return tmpBoard;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
setBoard(pBoard)
|
|
782
|
+
{
|
|
783
|
+
if (!this._FlowView || !pBoard) return;
|
|
784
|
+
this._FlowView.setFlowData(pBoard);
|
|
785
|
+
this._FlowView.renderFlow();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
onBeforeUnload()
|
|
789
|
+
{
|
|
790
|
+
if (this._PasteWired)
|
|
791
|
+
{
|
|
792
|
+
document.removeEventListener('paste', this._boundOnPaste);
|
|
793
|
+
this._PasteWired = false;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
module.exports = PictViewMoodboard;
|
|
799
|
+
module.exports.default_configuration = _ViewConfiguration;
|
|
800
|
+
module.exports.NOTE_COLORS = _NOTE_COLORS;
|