Layout constraints example (#3996)
Adds an example of implementing layout contraints using bindings. ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [ ] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [x] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [x] `feature` — New feature - [ ] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Adds an example of how to use bindings to create layout constraints --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
8250112061
commit
978de17a12
3 changed files with 595 additions and 0 deletions
373
apps/examples/src/examples/layout-bindings/LayoutExample.tsx
Normal file
373
apps/examples/src/examples/layout-bindings/LayoutExample.tsx
Normal file
|
@ -0,0 +1,373 @@
|
|||
import {
|
||||
BindingOnChangeOptions,
|
||||
BindingOnCreateOptions,
|
||||
BindingOnDeleteOptions,
|
||||
BindingOnShapeChangeOptions,
|
||||
BindingUtil,
|
||||
HTMLContainer,
|
||||
IndexKey,
|
||||
RecordProps,
|
||||
Rectangle2d,
|
||||
ShapeUtil,
|
||||
T,
|
||||
TLBaseBinding,
|
||||
TLBaseShape,
|
||||
TLOnTranslateHandler,
|
||||
Tldraw,
|
||||
Vec,
|
||||
clamp,
|
||||
createBindingId,
|
||||
getIndexBetween,
|
||||
} from 'tldraw'
|
||||
import snapShot from './snapshot.json'
|
||||
|
||||
// The container shapes that can contain element shapes
|
||||
|
||||
const CONTAINER_PADDING = 24
|
||||
|
||||
type ContainerShape = TLBaseShape<'element', { height: number; width: number }>
|
||||
|
||||
class ContainerShapeUtil extends ShapeUtil<ContainerShape> {
|
||||
static override type = 'container' as const
|
||||
static override props: RecordProps<ContainerShape> = { height: T.number, width: T.number }
|
||||
|
||||
override getDefaultProps() {
|
||||
return {
|
||||
width: 100 + CONTAINER_PADDING * 2,
|
||||
height: 100 + CONTAINER_PADDING * 2,
|
||||
}
|
||||
}
|
||||
|
||||
override canBind({
|
||||
fromShapeType,
|
||||
toShapeType,
|
||||
bindingType,
|
||||
}: {
|
||||
fromShapeType: string
|
||||
toShapeType: string
|
||||
bindingType: string
|
||||
}) {
|
||||
return fromShapeType === 'container' && toShapeType === 'element' && bindingType === 'layout'
|
||||
}
|
||||
override canEdit = () => false
|
||||
override canResize = () => false
|
||||
override hideRotateHandle = () => true
|
||||
override isAspectRatioLocked = () => true
|
||||
|
||||
override getGeometry(shape: ContainerShape) {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.width,
|
||||
height: shape.props.height,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
override component(shape: ContainerShape) {
|
||||
return (
|
||||
<HTMLContainer
|
||||
style={{
|
||||
backgroundColor: '#efefef',
|
||||
width: shape.props.width,
|
||||
height: shape.props.height,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
override indicator(shape: ContainerShape) {
|
||||
return <rect width={shape.props.width} height={shape.props.height} />
|
||||
}
|
||||
}
|
||||
|
||||
// The element shapes that can be placed inside the container shapes
|
||||
|
||||
type ElementShape = TLBaseShape<'element', { color: string }>
|
||||
|
||||
class ElementShapeUtil extends ShapeUtil<ElementShape> {
|
||||
static override type = 'element' as const
|
||||
static override props: RecordProps<ElementShape> = {
|
||||
color: T.string,
|
||||
}
|
||||
|
||||
override getDefaultProps() {
|
||||
return {
|
||||
color: '#AEC6CF',
|
||||
}
|
||||
}
|
||||
|
||||
override canBind({
|
||||
fromShapeType,
|
||||
toShapeType,
|
||||
bindingType,
|
||||
}: {
|
||||
fromShapeType: string
|
||||
toShapeType: string
|
||||
bindingType: string
|
||||
}) {
|
||||
return fromShapeType === 'container' && toShapeType === 'element' && bindingType === 'layout'
|
||||
}
|
||||
override canEdit = () => false
|
||||
override canResize = () => false
|
||||
override hideRotateHandle = () => true
|
||||
override isAspectRatioLocked = () => true
|
||||
|
||||
override getGeometry() {
|
||||
return new Rectangle2d({
|
||||
width: 100,
|
||||
height: 100,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
override component(shape: ElementShape) {
|
||||
return <HTMLContainer style={{ backgroundColor: shape.props.color }}></HTMLContainer>
|
||||
}
|
||||
|
||||
override indicator() {
|
||||
return <rect width={100} height={100} />
|
||||
}
|
||||
|
||||
private getTargetContainer = (shape: ElementShape, pageAnchor: Vec) => {
|
||||
// Find the container shape that the element is being dropped on
|
||||
return this.editor.getShapeAtPoint(pageAnchor, {
|
||||
hitInside: true,
|
||||
filter: (otherShape) =>
|
||||
this.editor.canBindShapes({ fromShape: otherShape, toShape: shape, binding: 'layout' }),
|
||||
}) as ContainerShape | undefined
|
||||
}
|
||||
|
||||
private getBindingIndexForPosition = (
|
||||
shape: ElementShape,
|
||||
container: ContainerShape,
|
||||
pageAnchor: Vec
|
||||
) => {
|
||||
// All the layout bindings from the container
|
||||
const allBindings = this.editor
|
||||
.getBindingsFromShape<LayoutBinding>(container, 'layout')
|
||||
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
|
||||
|
||||
// Those bindings that don't involve the element
|
||||
const siblings = allBindings.filter((b) => b.toId !== shape.id)
|
||||
|
||||
// Get the relative x position of the element center in the container
|
||||
// Where should the element be placed? min index at left, max index + 1
|
||||
const order = clamp(
|
||||
Math.round((pageAnchor.x - container.x - CONTAINER_PADDING) / (100 + CONTAINER_PADDING)),
|
||||
0,
|
||||
siblings.length + 1
|
||||
)
|
||||
|
||||
// Get a fractional index between the two siblings
|
||||
const belowSib = allBindings[order - 1]
|
||||
const aboveSib = allBindings[order]
|
||||
let index: IndexKey
|
||||
|
||||
if (belowSib?.toId === shape.id) {
|
||||
index = belowSib.props.index
|
||||
} else if (aboveSib?.toId === shape.id) {
|
||||
index = aboveSib.props.index
|
||||
} else {
|
||||
index = getIndexBetween(belowSib?.props.index, aboveSib?.props.index)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
override onTranslateStart = (shape: ElementShape) => {
|
||||
// Update all the layout bindings for this shape to be placeholders
|
||||
this.editor.updateBindings(
|
||||
this.editor.getBindingsToShape<LayoutBinding>(shape, 'layout').map((binding) => ({
|
||||
...binding,
|
||||
props: { ...binding.props, placeholder: true },
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
override onTranslate: TLOnTranslateHandler<ElementShape> | undefined = (
|
||||
_,
|
||||
shape: ElementShape
|
||||
) => {
|
||||
// Find the center of the element shape
|
||||
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: 50, y: 50 })
|
||||
|
||||
// Find the container shape that the element is being dropped on
|
||||
const targetContainer = this.getTargetContainer(shape, pageAnchor)
|
||||
|
||||
if (!targetContainer) {
|
||||
// Delete all the bindings to the element
|
||||
const bindings = this.editor.getBindingsToShape<LayoutBinding>(shape, 'layout')
|
||||
this.editor.deleteBindings(bindings)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the index for the new binding
|
||||
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
|
||||
|
||||
// Is there an existing binding already between the container and the shape?
|
||||
const existingBinding = this.editor
|
||||
.getBindingsFromShape<LayoutBinding>(targetContainer, 'layout')
|
||||
.find((b) => b.toId === shape.id)
|
||||
|
||||
if (existingBinding) {
|
||||
// If a binding already exists, update it
|
||||
if (existingBinding.props.index === index) return
|
||||
this.editor.updateBinding<LayoutBinding>({
|
||||
...existingBinding,
|
||||
props: {
|
||||
...existingBinding.props,
|
||||
placeholder: true,
|
||||
index,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// ...otherwise, create a new one
|
||||
this.editor.createBinding<LayoutBinding>({
|
||||
id: createBindingId(),
|
||||
type: 'layout',
|
||||
fromId: targetContainer.id,
|
||||
toId: shape.id,
|
||||
props: {
|
||||
index,
|
||||
placeholder: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override onTranslateEnd = (_: ElementShape, shape: ElementShape) => {
|
||||
// Find the center of the element shape
|
||||
const pageAnchor = this.editor.getShapePageTransform(shape).applyToPoint({ x: 50, y: 50 })
|
||||
|
||||
// Find the container shape that the element is being dropped on
|
||||
const targetContainer = this.getTargetContainer(shape, pageAnchor)
|
||||
|
||||
// No target container? no problem
|
||||
if (!targetContainer) return
|
||||
|
||||
// get the index for the new binding
|
||||
const index = this.getBindingIndexForPosition(shape, targetContainer, pageAnchor)
|
||||
|
||||
// delete all the previous bindings for this shape
|
||||
this.editor.deleteBindings(this.editor.getBindingsToShape<LayoutBinding>(shape, 'layout'))
|
||||
|
||||
// ...and then create a new one
|
||||
this.editor.createBinding<LayoutBinding>({
|
||||
id: createBindingId(),
|
||||
type: 'layout',
|
||||
fromId: targetContainer.id,
|
||||
toId: shape.id,
|
||||
props: {
|
||||
index,
|
||||
placeholder: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The binding between the element shapes and the container shapes
|
||||
|
||||
type LayoutBinding = TLBaseBinding<
|
||||
'layout',
|
||||
{
|
||||
index: IndexKey
|
||||
placeholder: boolean
|
||||
}
|
||||
>
|
||||
|
||||
class LayoutBindingUtil extends BindingUtil<LayoutBinding> {
|
||||
static override type = 'layout' as const
|
||||
|
||||
override getDefaultProps() {
|
||||
return {
|
||||
index: 'a1' as IndexKey,
|
||||
placeholder: true,
|
||||
}
|
||||
}
|
||||
|
||||
override onAfterCreate({ binding }: BindingOnCreateOptions<LayoutBinding>): void {
|
||||
this.updateElementsForContainer(binding)
|
||||
}
|
||||
|
||||
override onAfterChange({ bindingAfter }: BindingOnChangeOptions<LayoutBinding>): void {
|
||||
this.updateElementsForContainer(bindingAfter)
|
||||
}
|
||||
|
||||
override onAfterChangeFromShape({ binding }: BindingOnShapeChangeOptions<LayoutBinding>): void {
|
||||
this.updateElementsForContainer(binding)
|
||||
}
|
||||
|
||||
override onAfterDelete({ binding }: BindingOnDeleteOptions<LayoutBinding>): void {
|
||||
this.updateElementsForContainer(binding)
|
||||
}
|
||||
|
||||
private updateElementsForContainer({
|
||||
props: { placeholder },
|
||||
fromId: containerId,
|
||||
toId,
|
||||
}: LayoutBinding) {
|
||||
// Get all of the bindings from the layout container
|
||||
const container = this.editor.getShape<ContainerShape>(containerId)
|
||||
if (!container) return
|
||||
|
||||
const bindings = this.editor
|
||||
.getBindingsFromShape<LayoutBinding>(container, 'layout')
|
||||
.sort((a, b) => (a.props.index > b.props.index ? 1 : -1))
|
||||
if (bindings.length === 0) return
|
||||
|
||||
for (let i = 0; i < bindings.length; i++) {
|
||||
const binding = bindings[i]
|
||||
|
||||
if (toId === binding.toId && placeholder) continue
|
||||
|
||||
const offset = new Vec(CONTAINER_PADDING + i * (100 + CONTAINER_PADDING), CONTAINER_PADDING)
|
||||
|
||||
const shape = this.editor.getShape<ElementShape>(binding.toId)
|
||||
if (!shape) continue
|
||||
|
||||
const point = this.editor.getPointInParentSpace(
|
||||
shape,
|
||||
this.editor.getShapePageTransform(container)!.applyToPoint(offset)
|
||||
)
|
||||
|
||||
if (shape.x !== point.x || shape.y !== point.y) {
|
||||
this.editor.updateShape({
|
||||
id: binding.toId,
|
||||
type: 'element',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const width =
|
||||
CONTAINER_PADDING +
|
||||
(bindings.length * 100 + (bindings.length - 1) * CONTAINER_PADDING) +
|
||||
CONTAINER_PADDING
|
||||
|
||||
const height = CONTAINER_PADDING + 100 + CONTAINER_PADDING
|
||||
|
||||
if (width !== container.props.width || height !== container.props.height) {
|
||||
this.editor.updateShape({
|
||||
id: container.id,
|
||||
type: 'container',
|
||||
props: { width, height },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function LayoutExample() {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
// @ts-ignore
|
||||
snapshot={snapShot}
|
||||
onMount={(editor) => {
|
||||
;(window as any).editor = editor
|
||||
}}
|
||||
shapeUtils={[ContainerShapeUtil, ElementShapeUtil]}
|
||||
bindingUtils={[LayoutBindingUtil]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
12
apps/examples/src/examples/layout-bindings/README.md
Normal file
12
apps/examples/src/examples/layout-bindings/README.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
title: Layout constraints (bindings)
|
||||
component: ./LayoutExample.tsx
|
||||
category: shapes/tools
|
||||
keywords: [constraints, group, shape, custom, bindings, drag, drop, position]
|
||||
---
|
||||
|
||||
How to constrain shapes to a layout using bindings.
|
||||
|
||||
---
|
||||
|
||||
You can use bindings to make shapes respond to changes to other shapes. This is useful for enforcing layout constraints
|
210
apps/examples/src/examples/layout-bindings/snapshot.json
Normal file
210
apps/examples/src/examples/layout-bindings/snapshot.json
Normal file
|
@ -0,0 +1,210 @@
|
|||
{
|
||||
"store": {
|
||||
"document:document": {
|
||||
"gridSize": 10,
|
||||
"name": "",
|
||||
"meta": {},
|
||||
"id": "document:document",
|
||||
"typeName": "document"
|
||||
},
|
||||
"page:page": {
|
||||
"meta": {},
|
||||
"id": "page:page",
|
||||
"name": "Page 1",
|
||||
"index": "a1",
|
||||
"typeName": "page"
|
||||
},
|
||||
"shape:f4LKGB_8M2qsyWGpHR5Dq": {
|
||||
"x": 30.9375,
|
||||
"y": 69.48828125,
|
||||
"rotation": 0,
|
||||
"isLocked": false,
|
||||
"opacity": 1,
|
||||
"meta": {},
|
||||
"id": "shape:f4LKGB_8M2qsyWGpHR5Dq",
|
||||
"type": "container",
|
||||
"parentId": "page:page",
|
||||
"index": "a1",
|
||||
"props": {
|
||||
"width": 644,
|
||||
"height": 148
|
||||
},
|
||||
"typeName": "shape"
|
||||
},
|
||||
"shape:2oThF4kJ4v31xqKN5lvq2": {
|
||||
"x": 550.9375,
|
||||
"y": 93.48828125,
|
||||
"rotation": 0,
|
||||
"isLocked": false,
|
||||
"opacity": 1,
|
||||
"meta": {},
|
||||
"id": "shape:2oThF4kJ4v31xqKN5lvq2",
|
||||
"type": "element",
|
||||
"props": {
|
||||
"color": "#5BCEFA"
|
||||
},
|
||||
"parentId": "page:page",
|
||||
"index": "a2",
|
||||
"typeName": "shape"
|
||||
},
|
||||
"shape:K2vk_VTaNh-ANaRNOAvgY": {
|
||||
"x": 426.9375,
|
||||
"y": 93.48828125,
|
||||
"rotation": 0,
|
||||
"isLocked": false,
|
||||
"opacity": 1,
|
||||
"meta": {},
|
||||
"id": "shape:K2vk_VTaNh-ANaRNOAvgY",
|
||||
"type": "element",
|
||||
"props": {
|
||||
"color": "#F5A9B8"
|
||||
},
|
||||
"parentId": "page:page",
|
||||
"index": "a3",
|
||||
"typeName": "shape"
|
||||
},
|
||||
"shape:6uouhIK7PvyIRNQHACf-d": {
|
||||
"x": 302.9375,
|
||||
"y": 93.48828125,
|
||||
"rotation": 0,
|
||||
"isLocked": false,
|
||||
"opacity": 1,
|
||||
"meta": {},
|
||||
"id": "shape:6uouhIK7PvyIRNQHACf-d",
|
||||
"type": "element",
|
||||
"props": {
|
||||
"color": "#FFFFFF"
|
||||
},
|
||||
"parentId": "page:page",
|
||||
"index": "a4",
|
||||
"typeName": "shape"
|
||||
},
|
||||
"shape:GTQq2qxkWPHEK7KMIRtsh": {
|
||||
"x": 54.9375,
|
||||
"y": 93.48828125,
|
||||
"rotation": 0,
|
||||
"isLocked": false,
|
||||
"opacity": 1,
|
||||
"meta": {},
|
||||
"id": "shape:GTQq2qxkWPHEK7KMIRtsh",
|
||||
"type": "element",
|
||||
"props": {
|
||||
"color": "#5BCEFA"
|
||||
},
|
||||
"parentId": "page:page",
|
||||
"index": "a5",
|
||||
"typeName": "shape"
|
||||
},
|
||||
"shape:05jMujN6A0sIp6zzHMpbV": {
|
||||
"x": 178.9375,
|
||||
"y": 93.48828125,
|
||||
"rotation": 0,
|
||||
"isLocked": false,
|
||||
"opacity": 1,
|
||||
"meta": {},
|
||||
"id": "shape:05jMujN6A0sIp6zzHMpbV",
|
||||
"type": "element",
|
||||
"props": {
|
||||
"color": "#F5A9B8"
|
||||
},
|
||||
"parentId": "page:page",
|
||||
"index": "a6",
|
||||
"typeName": "shape"
|
||||
},
|
||||
"binding:iOBENBUHvzD8N7mBdIM5l": {
|
||||
"meta": {},
|
||||
"id": "binding:iOBENBUHvzD8N7mBdIM5l",
|
||||
"type": "layout",
|
||||
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
|
||||
"toId": "shape:05jMujN6A0sIp6zzHMpbV",
|
||||
"props": {
|
||||
"index": "a2",
|
||||
"placeholder": false
|
||||
},
|
||||
"typeName": "binding"
|
||||
},
|
||||
"binding:YTIeOALEmHJk6dczRpQmE": {
|
||||
"meta": {},
|
||||
"id": "binding:YTIeOALEmHJk6dczRpQmE",
|
||||
"type": "layout",
|
||||
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
|
||||
"toId": "shape:GTQq2qxkWPHEK7KMIRtsh",
|
||||
"props": {
|
||||
"index": "a1",
|
||||
"placeholder": false
|
||||
},
|
||||
"typeName": "binding"
|
||||
},
|
||||
"binding:n4LY_pVuLfjV1qpOTZX-U": {
|
||||
"meta": {},
|
||||
"id": "binding:n4LY_pVuLfjV1qpOTZX-U",
|
||||
"type": "layout",
|
||||
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
|
||||
"toId": "shape:6uouhIK7PvyIRNQHACf-d",
|
||||
"props": {
|
||||
"index": "a3",
|
||||
"placeholder": false
|
||||
},
|
||||
"typeName": "binding"
|
||||
},
|
||||
"binding:8XayRsWB_nxAH2833SYg1": {
|
||||
"meta": {},
|
||||
"id": "binding:8XayRsWB_nxAH2833SYg1",
|
||||
"type": "layout",
|
||||
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
|
||||
"toId": "shape:2oThF4kJ4v31xqKN5lvq2",
|
||||
"props": {
|
||||
"index": "a5",
|
||||
"placeholder": false
|
||||
},
|
||||
"typeName": "binding"
|
||||
},
|
||||
"binding:MTYuIRiEVTn2DyVChthry": {
|
||||
"meta": {},
|
||||
"id": "binding:MTYuIRiEVTn2DyVChthry",
|
||||
"type": "layout",
|
||||
"fromId": "shape:f4LKGB_8M2qsyWGpHR5Dq",
|
||||
"toId": "shape:K2vk_VTaNh-ANaRNOAvgY",
|
||||
"props": {
|
||||
"index": "a4",
|
||||
"placeholder": false
|
||||
},
|
||||
"typeName": "binding"
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"schemaVersion": 2,
|
||||
"sequences": {
|
||||
"com.tldraw.store": 4,
|
||||
"com.tldraw.asset": 1,
|
||||
"com.tldraw.camera": 1,
|
||||
"com.tldraw.document": 2,
|
||||
"com.tldraw.instance": 25,
|
||||
"com.tldraw.instance_page_state": 5,
|
||||
"com.tldraw.page": 1,
|
||||
"com.tldraw.instance_presence": 5,
|
||||
"com.tldraw.pointer": 1,
|
||||
"com.tldraw.shape": 4,
|
||||
"com.tldraw.asset.bookmark": 2,
|
||||
"com.tldraw.asset.image": 4,
|
||||
"com.tldraw.asset.video": 4,
|
||||
"com.tldraw.shape.group": 0,
|
||||
"com.tldraw.shape.text": 2,
|
||||
"com.tldraw.shape.bookmark": 2,
|
||||
"com.tldraw.shape.draw": 2,
|
||||
"com.tldraw.shape.geo": 9,
|
||||
"com.tldraw.shape.note": 7,
|
||||
"com.tldraw.shape.line": 5,
|
||||
"com.tldraw.shape.frame": 0,
|
||||
"com.tldraw.shape.arrow": 5,
|
||||
"com.tldraw.shape.highlight": 1,
|
||||
"com.tldraw.shape.embed": 4,
|
||||
"com.tldraw.shape.image": 3,
|
||||
"com.tldraw.shape.video": 2,
|
||||
"com.tldraw.shape.container": 0,
|
||||
"com.tldraw.shape.element": 0,
|
||||
"com.tldraw.binding.arrow": 0,
|
||||
"com.tldraw.binding.layout": 0
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue