@formicoidea/labre-framework-edgy 0.23.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/package.json +24 -0
- package/src/consts.ts +50 -0
- package/src/descriptor.ts +8 -0
- package/src/effects.ts +14 -0
- package/src/element-renderer.ts +208 -0
- package/src/element-view.ts +145 -0
- package/src/index.ts +1 -0
- package/src/label-layout.ts +105 -0
- package/src/node/consts.ts +56 -0
- package/src/node/node-renderer.ts +64 -0
- package/src/node/node-view.ts +33 -0
- package/src/templates/index.ts +254 -0
- package/src/toolbar/config.ts +96 -0
- package/src/toolbar/edgy-menu.ts +242 -0
- package/src/toolbar/edgy-senior-button.ts +102 -0
- package/src/toolbar/icons.ts +38 -0
- package/src/toolbar/node-config.ts +202 -0
- package/src/toolbar/senior-tool.ts +11 -0
- package/src/view.ts +39 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { DefaultTool } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { EmptyTool } from '@formicoidea/labre-core/gfx/pointer';
|
|
3
|
+
import { EdgelessToolbarToolMixin } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
|
|
4
|
+
import { SignalWatcher } from '@formicoidea/labre-core/global/lit';
|
|
5
|
+
import { css, html, LitElement } from 'lit';
|
|
6
|
+
|
|
7
|
+
import { edgyToolbarIcon } from './icons';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Main toolbar button (colored facets glyph) that opens the EDGY toolbox
|
|
11
|
+
* sub-menu above the toolbar. Mirrors the Wardley senior button.
|
|
12
|
+
*/
|
|
13
|
+
export class EdgelessEdgySeniorButton extends EdgelessToolbarToolMixin(
|
|
14
|
+
SignalWatcher(LitElement)
|
|
15
|
+
) {
|
|
16
|
+
static override styles = css`
|
|
17
|
+
:host,
|
|
18
|
+
.edgy-button {
|
|
19
|
+
display: block;
|
|
20
|
+
width: 100%;
|
|
21
|
+
height: 100%;
|
|
22
|
+
}
|
|
23
|
+
:host {
|
|
24
|
+
position: relative;
|
|
25
|
+
}
|
|
26
|
+
.edgy-root {
|
|
27
|
+
width: 100%;
|
|
28
|
+
height: 64px;
|
|
29
|
+
position: relative;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: flex-end;
|
|
34
|
+
justify-content: center;
|
|
35
|
+
}
|
|
36
|
+
.edgy-card {
|
|
37
|
+
--y: -4px;
|
|
38
|
+
--s: 1;
|
|
39
|
+
position: absolute;
|
|
40
|
+
bottom: 0;
|
|
41
|
+
width: 54px;
|
|
42
|
+
height: 54px;
|
|
43
|
+
transform: translateY(var(--y)) scale(var(--s));
|
|
44
|
+
translate: var(--active-x, 0) var(--active-y, 0);
|
|
45
|
+
rotate: var(--active-r, -2deg);
|
|
46
|
+
scale: var(--active-s, 1);
|
|
47
|
+
transition: transform 0.3s ease, translate 0.3s ease, rotate 0.3s ease,
|
|
48
|
+
scale 0.3s ease;
|
|
49
|
+
}
|
|
50
|
+
.edgy-card svg {
|
|
51
|
+
display: block;
|
|
52
|
+
width: 100%;
|
|
53
|
+
height: 100%;
|
|
54
|
+
}
|
|
55
|
+
.edgy-root:hover .edgy-card {
|
|
56
|
+
--y: -10px;
|
|
57
|
+
--s: 1.07;
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
override enableActiveBackground = true;
|
|
62
|
+
|
|
63
|
+
override type = EmptyTool;
|
|
64
|
+
|
|
65
|
+
private _toggleMenu() {
|
|
66
|
+
if (this.popper) {
|
|
67
|
+
this.popper.dispose();
|
|
68
|
+
this.popper = null;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.setEdgelessTool(DefaultTool);
|
|
72
|
+
const menu = this.createPopper('edgeless-edgy-menu', this);
|
|
73
|
+
menu.element.edgeless = this.edgeless;
|
|
74
|
+
|
|
75
|
+
const el = menu.element as HTMLElement;
|
|
76
|
+
const wrap = el.parentElement;
|
|
77
|
+
if (wrap) {
|
|
78
|
+
wrap.style.overflow = 'visible';
|
|
79
|
+
wrap.style.justifyContent = 'flex-end';
|
|
80
|
+
}
|
|
81
|
+
Object.assign(el.style, {
|
|
82
|
+
position: 'static',
|
|
83
|
+
width: 'max-content',
|
|
84
|
+
maxWidth: 'calc(100vw - 16px)',
|
|
85
|
+
marginLeft: '0',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override render() {
|
|
90
|
+
return html`<edgeless-toolbar-button
|
|
91
|
+
class="edgy-button"
|
|
92
|
+
.tooltip=${this.popper ? '' : 'EDGY'}
|
|
93
|
+
.tooltipOffset=${4}
|
|
94
|
+
.active=${!!this.popper}
|
|
95
|
+
@click=${this._toggleMenu}
|
|
96
|
+
>
|
|
97
|
+
<div class="edgy-root">
|
|
98
|
+
<div class="edgy-card">${edgyToolbarIcon}</div>
|
|
99
|
+
</div>
|
|
100
|
+
</edgeless-toolbar-button>`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { svg } from 'lit';
|
|
2
|
+
|
|
3
|
+
/** Colored EDGY facets glyph for the main toolbar button (3 overlapping circles). */
|
|
4
|
+
export const edgyToolbarIcon = svg`<svg width="100%" height="100%" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
5
|
+
<g opacity="0.95">
|
|
6
|
+
<circle cx="22" cy="24" r="13" fill="#00ea4e"/>
|
|
7
|
+
<circle cx="34" cy="24" r="13" fill="#034cee"/>
|
|
8
|
+
<circle cx="28" cy="34" r="13" fill="#ff0056"/>
|
|
9
|
+
</g>
|
|
10
|
+
</svg>`;
|
|
11
|
+
|
|
12
|
+
/** Menu icon — the facets diagram (colored mini Venn). */
|
|
13
|
+
export const edgyFacetsIcon = svg`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
14
|
+
<circle cx="9.5" cy="10" r="6" fill="#00ea4e" opacity="0.92"/>
|
|
15
|
+
<circle cx="14.5" cy="10" r="6" fill="#034cee" opacity="0.92"/>
|
|
16
|
+
<circle cx="12" cy="14.5" r="6" fill="#ff0056" opacity="0.92"/>
|
|
17
|
+
</svg>`;
|
|
18
|
+
|
|
19
|
+
/** People — person glyph (official Icon-People), uses currentColor. */
|
|
20
|
+
export const edgyPeopleIcon = svg`<svg width="24" height="24" viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
21
|
+
<path d="m16,19c-3.308,0-6-2.692-6-6v-4c0-3.308,2.692-6,6-6s6,2.692,6,6v4c0,3.308-2.692,6-6,6Zm0-14c-2.206,0-4,1.794-4,4v4c0,2.206,1.794,4,4,4s4-1.794,4-4v-4c0-2.206-1.794-4-4-4Z"/>
|
|
22
|
+
<path d="m29,30H3v-3.5c0-3.308,2.692-6,6-6h14c3.308,0,6,2.692,6,6v3.5Zm-24-2h22v-1.5c0-2.206-1.794-4-4-4h-14c-2.206,0-4,1.794-4,4v1.5Z"/>
|
|
23
|
+
</svg>`;
|
|
24
|
+
|
|
25
|
+
/** Outcome — lightly rounded rectangle. */
|
|
26
|
+
export const edgyOutcomeIcon = svg`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
27
|
+
<rect x="3.5" y="6.5" width="17" height="11" rx="2" stroke="currentColor" stroke-width="1.6"/>
|
|
28
|
+
</svg>`;
|
|
29
|
+
|
|
30
|
+
/** Object — plain rectangle. */
|
|
31
|
+
export const edgyObjectIcon = svg`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
32
|
+
<rect x="3.5" y="6.5" width="17" height="11" stroke="currentColor" stroke-width="1.6"/>
|
|
33
|
+
</svg>`;
|
|
34
|
+
|
|
35
|
+
/** Activity — right-pointing chevron. */
|
|
36
|
+
export const edgyActivityIcon = svg`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
37
|
+
<path d="M3.5 6.5 H15 L20.5 12 L15 17.5 H3.5 Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
|
38
|
+
</svg>`;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { EdgelessCRUDIdentifier } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import {
|
|
3
|
+
packColor,
|
|
4
|
+
type PickColorEvent,
|
|
5
|
+
} from '@formicoidea/labre-core/components/color-picker';
|
|
6
|
+
import { shapeToolbarConfig } from '@formicoidea/labre-core/gfx/shape';
|
|
7
|
+
import {
|
|
8
|
+
type Color,
|
|
9
|
+
DefaultTheme,
|
|
10
|
+
isTransparent,
|
|
11
|
+
LineWidth,
|
|
12
|
+
type Palette,
|
|
13
|
+
resolveColor,
|
|
14
|
+
ShapeElementModel,
|
|
15
|
+
StrokeStyle,
|
|
16
|
+
} from '@formicoidea/labre-core/model';
|
|
17
|
+
import {
|
|
18
|
+
type ToolbarContext,
|
|
19
|
+
type ToolbarModuleConfig,
|
|
20
|
+
ToolbarModuleExtension,
|
|
21
|
+
} from '@formicoidea/labre-core/shared/services';
|
|
22
|
+
import { getMostCommonValue } from '@formicoidea/labre-core/shared/utils';
|
|
23
|
+
import { BlockFlavourIdentifier } from '@formicoidea/labre-core/std';
|
|
24
|
+
import { html } from 'lit';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The typical EDGY palette, surfaced as ready-made swatches in the EDGY node
|
|
28
|
+
* color picker (facet + intersection colours, saturated then pastel), followed
|
|
29
|
+
* by the default editor palette.
|
|
30
|
+
*/
|
|
31
|
+
const EDGY_PALETTES: Palette[] = [
|
|
32
|
+
{ key: 'Identity', value: '#00ea4e' },
|
|
33
|
+
{ key: 'Architecture', value: '#034cee' },
|
|
34
|
+
{ key: 'Experience', value: '#ff0056' },
|
|
35
|
+
{ key: 'Organisation', value: '#00caf4' },
|
|
36
|
+
{ key: 'Brand', value: '#ffa500' },
|
|
37
|
+
{ key: 'Product', value: '#cf00ff' },
|
|
38
|
+
{ key: 'Identity light', value: '#80ffb7' },
|
|
39
|
+
{ key: 'Architecture light', value: '#a6c0ff' },
|
|
40
|
+
{ key: 'Experience light', value: '#ff99bd' },
|
|
41
|
+
{ key: 'Organisation light', value: '#80eaff' },
|
|
42
|
+
{ key: 'Brand light', value: '#ffd580' },
|
|
43
|
+
{ key: 'Product light', value: '#e599ff' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* From the default editor palette we keep ONLY the neutrals (greys, white,
|
|
48
|
+
* black, transparent) — the historical colours are dropped in favour of the
|
|
49
|
+
* EDGY swatches above.
|
|
50
|
+
*/
|
|
51
|
+
const NEUTRAL_KEY = /grey|gray|white|black|transparent/i;
|
|
52
|
+
|
|
53
|
+
const EDGY_PALETTE_LIST: Palette[] = [
|
|
54
|
+
...EDGY_PALETTES,
|
|
55
|
+
...DefaultTheme.Palettes.filter(p => NEUTRAL_KEY.test(p.key)),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Mirror of the shape color action's text-color rule.
|
|
59
|
+
function getTextColor(fillColor: Color) {
|
|
60
|
+
if (fillColor === DefaultTheme.black) return DefaultTheme.white;
|
|
61
|
+
if (fillColor === DefaultTheme.white) return DefaultTheme.black;
|
|
62
|
+
return DefaultTheme.shapeTextColor;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* EDGY fill / stroke colour picker — identical to the shape one but seeded with
|
|
67
|
+
* the EDGY palette swatches (`.palettes`).
|
|
68
|
+
*/
|
|
69
|
+
const edgyColorAction = {
|
|
70
|
+
id: 'e.color',
|
|
71
|
+
when(ctx: ToolbarContext) {
|
|
72
|
+
return ctx.getSurfaceModelsByType(ShapeElementModel).length > 0;
|
|
73
|
+
},
|
|
74
|
+
content(ctx: ToolbarContext) {
|
|
75
|
+
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
|
|
76
|
+
if (!models.length) return null;
|
|
77
|
+
|
|
78
|
+
const enableCustomColor = ctx.features.getFlag('enable_color_picker');
|
|
79
|
+
const theme = ctx.theme.edgeless$.value;
|
|
80
|
+
|
|
81
|
+
const firstModel = models[0];
|
|
82
|
+
const originalFillColor = firstModel.fillColor;
|
|
83
|
+
const originalStrokeColor = firstModel.strokeColor;
|
|
84
|
+
|
|
85
|
+
const mapped = models.map(
|
|
86
|
+
({ filled, fillColor, strokeColor, strokeWidth, strokeStyle }) => ({
|
|
87
|
+
fillColor: filled
|
|
88
|
+
? resolveColor(fillColor, theme)
|
|
89
|
+
: DefaultTheme.transparent,
|
|
90
|
+
strokeColor: resolveColor(strokeColor, theme),
|
|
91
|
+
strokeWidth,
|
|
92
|
+
strokeStyle,
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
const fillColor =
|
|
96
|
+
getMostCommonValue(mapped, 'fillColor') ??
|
|
97
|
+
resolveColor(DefaultTheme.shapeFillColor, theme);
|
|
98
|
+
const strokeColor =
|
|
99
|
+
getMostCommonValue(mapped, 'strokeColor') ??
|
|
100
|
+
resolveColor(DefaultTheme.shapeStrokeColor, theme);
|
|
101
|
+
const strokeWidth =
|
|
102
|
+
getMostCommonValue(mapped, 'strokeWidth') ?? LineWidth.Four;
|
|
103
|
+
const strokeStyle =
|
|
104
|
+
getMostCommonValue(mapped, 'strokeStyle') ?? StrokeStyle.Solid;
|
|
105
|
+
|
|
106
|
+
const pickColorWrapper =
|
|
107
|
+
(field: string, pickCallback: (palette: Palette) => void) =>
|
|
108
|
+
(e: CustomEvent<PickColorEvent>) => {
|
|
109
|
+
e.stopPropagation();
|
|
110
|
+
switch (e.detail.type) {
|
|
111
|
+
case 'pick':
|
|
112
|
+
pickCallback(e.detail.detail);
|
|
113
|
+
break;
|
|
114
|
+
case 'start':
|
|
115
|
+
ctx.store.captureSync();
|
|
116
|
+
models.forEach(model => model.stash(field));
|
|
117
|
+
break;
|
|
118
|
+
case 'end':
|
|
119
|
+
ctx.store.transact(() => {
|
|
120
|
+
models.forEach(model => model.pop(field));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const onPickFillColor = pickColorWrapper('fillColor', palette => {
|
|
126
|
+
const value = palette.value;
|
|
127
|
+
const filled = isTransparent(value);
|
|
128
|
+
const props = packColor('fillColor', value);
|
|
129
|
+
const crud = ctx.std.get(EdgelessCRUDIdentifier);
|
|
130
|
+
models.forEach(model => {
|
|
131
|
+
if (filled && !model.filled) {
|
|
132
|
+
const color = getTextColor(value);
|
|
133
|
+
Object.assign(props, { filled, color });
|
|
134
|
+
}
|
|
135
|
+
crud.updateElement(model.id, props);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const onPickStrokeColor = pickColorWrapper('strokeColor', palette => {
|
|
140
|
+
const props = packColor('strokeColor', palette.value);
|
|
141
|
+
const crud = ctx.std.get(EdgelessCRUDIdentifier);
|
|
142
|
+
models.forEach(model => crud.updateElement(model.id, props));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const onPickStrokeStyle = (
|
|
146
|
+
e: CustomEvent<{ type: string; value: number & StrokeStyle }>
|
|
147
|
+
) => {
|
|
148
|
+
e.stopPropagation();
|
|
149
|
+
const { type, value } = e.detail;
|
|
150
|
+
const crud = ctx.std.get(EdgelessCRUDIdentifier);
|
|
151
|
+
const props =
|
|
152
|
+
type === 'size'
|
|
153
|
+
? { strokeWidth: value as number }
|
|
154
|
+
: { strokeStyle: value as StrokeStyle };
|
|
155
|
+
for (const model of models) {
|
|
156
|
+
crud.updateElement(model.id, props);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return html`
|
|
161
|
+
<edgeless-shape-color-picker
|
|
162
|
+
@pickFillColor=${onPickFillColor}
|
|
163
|
+
@pickStrokeColor=${onPickStrokeColor}
|
|
164
|
+
@pickStrokeStyle=${onPickStrokeStyle}
|
|
165
|
+
.palettes=${EDGY_PALETTE_LIST}
|
|
166
|
+
.payload=${{
|
|
167
|
+
fillColor,
|
|
168
|
+
strokeColor,
|
|
169
|
+
strokeWidth,
|
|
170
|
+
strokeStyle,
|
|
171
|
+
originalFillColor,
|
|
172
|
+
originalStrokeColor,
|
|
173
|
+
theme,
|
|
174
|
+
enableCustomColor,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
</edgeless-shape-color-picker>
|
|
178
|
+
`;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* EDGY nodes are {@link ShapeElementModel} subclasses, so the shape toolbar's
|
|
184
|
+
* actions operate on them directly. We reuse the line-style + text actions, add
|
|
185
|
+
* the EDGY-seeded color picker, and drop the actions that don't fit an EDGY base
|
|
186
|
+
* shape (switch shape type, edit polygon vertices).
|
|
187
|
+
*/
|
|
188
|
+
const KEEP_FROM_SHAPE = (id: string) =>
|
|
189
|
+
id === 'd.style' || id === 'f.text' || id.startsWith('g.text-');
|
|
190
|
+
|
|
191
|
+
const edgyNodeToolbarConfig = {
|
|
192
|
+
actions: [
|
|
193
|
+
...shapeToolbarConfig.actions.filter(action => KEEP_FROM_SHAPE(action.id)),
|
|
194
|
+
edgyColorAction,
|
|
195
|
+
],
|
|
196
|
+
when: shapeToolbarConfig.when,
|
|
197
|
+
} as ToolbarModuleConfig;
|
|
198
|
+
|
|
199
|
+
export const edgyNodeToolbarExtension = ToolbarModuleExtension({
|
|
200
|
+
id: BlockFlavourIdentifier('affine:surface:edgyNode'),
|
|
201
|
+
config: edgyNodeToolbarConfig,
|
|
202
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SeniorToolExtension } from '@formicoidea/labre-core/widgets/edgeless-toolbar';
|
|
2
|
+
import { html } from 'lit';
|
|
3
|
+
|
|
4
|
+
export const edgySeniorTool = SeniorToolExtension('edgy', ({ block }) => {
|
|
5
|
+
return {
|
|
6
|
+
name: 'EDGY',
|
|
7
|
+
content: html`<edgeless-edgy-senior-button
|
|
8
|
+
.edgeless=${block}
|
|
9
|
+
></edgeless-edgy-senior-button>`,
|
|
10
|
+
};
|
|
11
|
+
});
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ViewExtensionContext,
|
|
3
|
+
ViewExtensionProvider,
|
|
4
|
+
} from '@formicoidea/labre-core/ext-loader';
|
|
5
|
+
import { extendTemplateCategory } from '@formicoidea/labre-core/gfx/template';
|
|
6
|
+
|
|
7
|
+
import { effects } from './effects';
|
|
8
|
+
import { edgyTemplateCategory } from './templates';
|
|
9
|
+
import { EdgyFacetsRendererExtension } from './element-renderer';
|
|
10
|
+
import { EdgyInteraction, EdgyView } from './element-view';
|
|
11
|
+
import { EdgyNodeRendererExtension } from './node/node-renderer';
|
|
12
|
+
import { EdgyNodeView } from './node/node-view';
|
|
13
|
+
import { edgyToolbarExtension } from './toolbar/config';
|
|
14
|
+
import { edgyNodeToolbarExtension } from './toolbar/node-config';
|
|
15
|
+
import { edgySeniorTool } from './toolbar/senior-tool';
|
|
16
|
+
|
|
17
|
+
export class EdgyViewExtension extends ViewExtensionProvider {
|
|
18
|
+
override name = 'affine-edgy-gfx';
|
|
19
|
+
|
|
20
|
+
override effect(): void {
|
|
21
|
+
super.effect();
|
|
22
|
+
effects();
|
|
23
|
+
extendTemplateCategory(edgyTemplateCategory);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override setup(context: ViewExtensionContext) {
|
|
27
|
+
super.setup(context);
|
|
28
|
+
context.register(EdgyView);
|
|
29
|
+
context.register(EdgyFacetsRendererExtension);
|
|
30
|
+
context.register(EdgyNodeView);
|
|
31
|
+
context.register(EdgyNodeRendererExtension);
|
|
32
|
+
if (this.isEdgeless(context.scope)) {
|
|
33
|
+
context.register(EdgyInteraction);
|
|
34
|
+
context.register(edgySeniorTool);
|
|
35
|
+
context.register(edgyToolbarExtension);
|
|
36
|
+
context.register(edgyNodeToolbarExtension);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|