Adds bounds

This commit is contained in:
Steve Ruiz 2021-05-12 23:08:53 +01:00
parent 3d52d9e9d2
commit 89d9ddcb1d
14 changed files with 531 additions and 41 deletions

View file

@ -1 +1,33 @@
export default function BoundsBg() {}
import state, { useSelector } from "state"
import styled from "styles"
export default function BoundsBg() {
const bounds = useSelector((state) => state.values.selectedBounds)
if (!bounds) return null
const { minX, minY, width, height } = bounds
return (
<StyledBoundsBg
x={minX}
y={minY}
width={width}
height={height}
onPointerDown={(e) => {
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS", {
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey || e.ctrlKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
}}
/>
)
}
const StyledBoundsBg = styled("rect", {
fill: "$boundsBg",
})

View file

@ -1 +1,297 @@
export default function Bounds() {}
import state, { useSelector } from "state"
import { motion } from "framer-motion"
import styled from "styles"
export default function Bounds() {
const bounds = useSelector((state) => state.values.selectedBounds)
const isBrushing = useSelector((state) => state.isIn("brushSelecting"))
const zoom = useSelector((state) => state.data.camera.zoom)
if (!bounds) return null
const { minX, minY, maxX, maxY, width, height } = bounds
const p = 4 / zoom
const cp = p * 2
return (
<g pointerEvents={isBrushing ? "none" : "all"}>
<StyledBounds
x={minX}
y={minY}
width={width}
height={height}
pointerEvents="none"
/>
<Corner
x={minX}
y={minY}
corner={0}
width={cp}
height={cp}
cursor="nwse-resize"
/>
<Corner
x={maxX}
y={minY}
corner={1}
width={cp}
height={cp}
cursor="nesw-resize"
/>
<Corner
x={maxX}
y={maxY}
corner={2}
width={cp}
height={cp}
cursor="nwse-resize"
/>
<Corner
x={minX}
y={maxY}
corner={3}
width={cp}
height={cp}
cursor="nesw-resize"
/>
<EdgeHorizontal
x={minX + p}
y={minY}
width={Math.max(0, width - p * 2)}
height={p}
onSelect={(e) => {
e.stopPropagation()
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 0,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
document.body.style.cursor = "ns-resize"
}}
/>
<EdgeVertical
x={maxX}
y={minY + p}
width={p}
height={Math.max(0, height - p * 2)}
onSelect={(e) => {
e.stopPropagation()
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 1,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
document.body.style.cursor = "ew-resize"
}}
/>
<EdgeHorizontal
x={minX + p}
y={maxY}
width={Math.max(0, width - p * 2)}
height={p}
onSelect={(e) => {
e.stopPropagation()
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 2,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
document.body.style.cursor = "ns-resize"
}}
/>
<EdgeVertical
x={minX}
y={minY + p}
width={p}
height={Math.max(0, height - p * 2)}
onSelect={(e) => {
e.stopPropagation()
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_EDGE", {
edge: 3,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
document.body.style.cursor = "ew-resize"
}}
/>
</g>
)
}
function Corner({
x,
y,
width,
height,
cursor,
onHover,
corner,
}: {
x: number
y: number
width: number
height: number
cursor: string
corner: number
onHover?: () => void
}) {
const isTop = corner === 0 || corner === 1
const isLeft = corner === 0 || corner === 3
return (
<g>
<motion.rect
x={x + width * (isLeft ? -1.25 : -0.5)} // + width * 2 * transformOffset[0]}
y={y + width * (isTop ? -1.25 : -0.5)} // + height * 2 * transformOffset[1]}
width={width * 1.75}
height={height * 1.75}
onPanEnd={restoreCursor}
onTap={restoreCursor}
onPointerDown={(e) => {
e.stopPropagation()
if (e.buttons !== 1) return
state.send("POINTED_ROTATE_CORNER", {
corner,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
document.body.style.cursor = "grabbing"
}}
style={{ cursor: "grab" }}
fill="transparent"
/>
<StyledCorner
x={x + width * -0.5}
y={y + height * -0.5}
width={width}
height={height}
onPointerEnter={onHover}
onPointerDown={(e) => {
e.stopPropagation()
if (e.buttons !== 1) return
state.send("POINTED_BOUNDS_CORNER", {
corner,
shiftKey: e.shiftKey,
optionKey: e.altKey,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
buttons: e.buttons,
})
document.body.style.cursor = "nesw-resize"
}}
onPanEnd={restoreCursor}
onTap={restoreCursor}
style={{ cursor }}
className="strokewidth-ui stroke-bounds fill-corner"
/>
</g>
)
}
function EdgeHorizontal({
x,
y,
width,
height,
onHover,
onSelect,
}: {
x: number
y: number
width: number
height: number
onHover?: () => void
onSelect?: (e: React.PointerEvent) => void
}) {
return (
<StyledEdge
x={x}
y={y - height / 2}
width={width}
height={height}
onPointerEnter={onHover}
onPointerDown={onSelect}
onPanEnd={restoreCursor}
onTap={restoreCursor}
style={{ cursor: "ns-resize" }}
direction="horizontal"
/>
)
}
function EdgeVertical({
x,
y,
width,
height,
onHover,
onSelect,
}: {
x: number
y: number
width: number
height: number
onHover?: () => void
onSelect?: (e: React.PointerEvent) => void
}) {
return (
<StyledEdge
x={x - width / 2}
y={y}
width={width}
height={height}
onPointerEnter={onHover}
onPointerDown={onSelect}
onPanEnd={restoreCursor}
onTap={restoreCursor}
direction="vertical"
/>
)
}
function restoreCursor() {
document.body.style.cursor = "default"
state.send("STOPPED_POINTING")
}
const StyledEdge = styled(motion.rect, {
stroke: "none",
fill: "none",
variant: {
direction: {
horizontal: { cursor: "ns-resize" },
vertical: { cursor: "ew-resize" },
},
},
})
const StyledCorner = styled(motion.rect, {
stroke: "$bounds",
fill: "#fff",
zStrokeWidth: 2,
})
const StyledBounds = styled("rect", {
fill: "none",
stroke: "$bounds",
zStrokeWidth: 2,
})

View file

@ -6,6 +6,8 @@ import useCamera from "hooks/useCamera"
import Page from "./page"
import Brush from "./brush"
import state from "state"
import Bounds from "./bounds"
import BoundsBg from "./bounds-bg"
export default function Canvas() {
const rCanvas = useRef<SVGSVGElement>(null)
@ -37,7 +39,9 @@ export default function Canvas() {
onPointerUp={handlePointerUp}
>
<MainGroup ref={rGroup}>
<BoundsBg />
<Page />
<Bounds />
<Brush />
</MainGroup>
</MainSVG>

View file

@ -1,19 +1,8 @@
import React, { useCallback, useRef } from "react"
import React, { useCallback, useRef, memo } from "react"
import state, { useSelector } from "state"
import styled from "styles"
import { getPointerEventInfo } from "utils/utils"
import { memo } from "react"
import Shapes from "lib/shapes"
/*
Gets the shape from the current page's shapes, using the
provided ID. Depending on the shape's type, return the
component for that type.
This component takes an SVG shape as its children. It handles
events for the shape as well as provides indicators for hover
and selected status
*/
import shapes from "lib/shapes"
import styled from "styles"
function Shape({ id }: { id: string }) {
const rGroup = useRef<SVGGElement>(null)
@ -66,7 +55,7 @@ function Shape({ id }: { id: string }) {
onPointerLeave={handlePointerLeave}
>
<defs>
{Shapes[shape.type] ? Shapes[shape.type].render(shape) : null}
{shapes[shape.type] ? shapes[shape.type].render(shape) : null}
</defs>
<HoverIndicator as="use" xlinkHref={"#" + id} />
<use xlinkHref={"#" + id} {...shape.style} />
@ -78,7 +67,7 @@ function Shape({ id }: { id: string }) {
const Indicator = styled("path", {
fill: "none",
stroke: "transparent",
strokeWidth: "max(1, calc(2 / var(--camera-zoom)))",
zStrokeWidth: 1,
pointerEvents: "none",
strokeLineCap: "round",
strokeLinejoin: "round",
@ -87,7 +76,7 @@ const Indicator = styled("path", {
const HoverIndicator = styled("path", {
fill: "none",
stroke: "transparent",
strokeWidth: "max(1, calc(8 / var(--camera-zoom)))",
zStrokeWidth: 8,
pointerEvents: "all",
strokeLinecap: "round",
strokeLinejoin: "round",

View file

@ -1,6 +1,7 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { BaseLibShape, CircleShape, ShapeType } from "types"
import { boundsCache } from "./index"
const Circle: BaseLibShape<ShapeType.Circle> = {
create(props): CircleShape {
@ -23,19 +24,26 @@ const Circle: BaseLibShape<ShapeType.Circle> = {
},
getBounds(shape) {
if (boundsCache.has(shape)) {
return boundsCache.get(shape)
}
const {
point: [cx, cy],
point: [x, y],
radius,
} = shape
return {
minX: cx,
maxX: cx + radius * 2,
minY: cy,
maxY: cy + radius * 2,
const bounds = {
minX: x,
maxX: x + radius * 2,
minY: y,
maxY: y + radius * 2,
width: radius * 2,
height: radius * 2,
}
boundsCache.set(shape, bounds)
return bounds
},
hitTest(shape, test) {

View file

@ -1,6 +1,7 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { BaseLibShape, DotShape, ShapeType } from "types"
import { boundsCache } from "./index"
const Dot: BaseLibShape<ShapeType.Dot> = {
create(props): DotShape {
@ -22,18 +23,25 @@ const Dot: BaseLibShape<ShapeType.Dot> = {
},
getBounds(shape) {
if (boundsCache.has(shape)) {
return boundsCache.get(shape)
}
const {
point: [cx, cy],
point: [x, y],
} = shape
return {
minX: cx,
maxX: cx + 4,
minY: cy,
maxY: cy + 4,
width: 4,
height: 4,
const bounds = {
minX: x,
maxX: x + 8,
minY: y,
maxY: y + 8,
width: 8,
height: 8,
}
boundsCache.set(shape, bounds)
return bounds
},
hitTest(shape, test) {

View file

@ -3,11 +3,15 @@ import Dot from "./dot"
import Polyline from "./polyline"
import Rectangle from "./rectangle"
import { ShapeType } from "types"
import { Bounds, Shape, ShapeType } from "types"
export default {
export const boundsCache = new WeakMap<Shape, Bounds>([])
const shapes = {
[ShapeType.Circle]: Circle,
[ShapeType.Dot]: Dot,
[ShapeType.Polyline]: Polyline,
[ShapeType.Rectangle]: Rectangle,
}
export default shapes

View file

@ -1,6 +1,7 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { BaseLibShape, PolylineShape, ShapeType } from "types"
import { boundsCache } from "./index"
const Polyline: BaseLibShape<ShapeType.Polyline> = {
create(props): PolylineShape {
@ -23,6 +24,10 @@ const Polyline: BaseLibShape<ShapeType.Polyline> = {
},
getBounds(shape) {
if (boundsCache.has(shape)) {
return boundsCache.get(shape)
}
let minX = 0
let minY = 0
let maxX = 0
@ -35,7 +40,7 @@ const Polyline: BaseLibShape<ShapeType.Polyline> = {
maxY = Math.max(y, maxY)
}
return {
const bounds = {
minX: minX + shape.point[0],
minY: minY + shape.point[1],
maxX: maxX + shape.point[0],
@ -43,6 +48,9 @@ const Polyline: BaseLibShape<ShapeType.Polyline> = {
width: maxX - minX,
height: maxY - minY,
}
boundsCache.set(shape, bounds)
return bounds
},
hitTest(shape) {

View file

@ -1,6 +1,7 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { BaseLibShape, RectangleShape, ShapeType } from "types"
import { boundsCache } from "./index"
const Rectangle: BaseLibShape<ShapeType.Rectangle> = {
create(props): RectangleShape {
@ -23,12 +24,16 @@ const Rectangle: BaseLibShape<ShapeType.Rectangle> = {
},
getBounds(shape) {
if (boundsCache.has(shape)) {
return boundsCache.get(shape)
}
const {
point: [x, y],
size: [width, height],
} = shape
return {
const bounds = {
minX: x,
maxX: x + width,
minY: y,
@ -36,6 +41,9 @@ const Rectangle: BaseLibShape<ShapeType.Rectangle> = {
width,
height,
}
boundsCache.set(shape, bounds)
return bounds
},
hitTest(shape) {

View file

@ -11,6 +11,7 @@
"@state-designer/react": "^1.7.1",
"@stitches/react": "^0.1.9",
"@types/uuid": "^8.3.0",
"framer-motion": "^4.1.16",
"next": "10.2.0",
"perfect-freehand": "^0.4.7",
"react": "17.0.2",

View file

@ -1,8 +1,9 @@
import { createSelectorHook, createState } from "@state-designer/react"
import { clamp, screenToWorld } from "utils/utils"
import { clamp, getCommonBounds, screenToWorld } from "utils/utils"
import * as vec from "utils/vec"
import { Data } from "types"
import { Bounds, Data, Shape, ShapeType } from "types"
import { defaultDocument } from "./data"
import Shapes from "lib/shapes"
import * as Sessions from "./sessions"
const initialData: Data = {
@ -131,6 +132,42 @@ const state = createState({
selectedIds(data) {
return new Set(data.selectedIds)
},
selectedBounds(data) {
const {
selectedIds,
currentPageId,
document: { pages },
} = data
return getCommonBounds(
...Array.from(selectedIds.values())
.map((id) => {
const shape = pages[currentPageId].shapes[id]
switch (shape.type) {
case ShapeType.Dot: {
return Shapes[shape.type].getBounds(shape)
}
case ShapeType.Circle: {
return Shapes[shape.type].getBounds(shape)
}
case ShapeType.Line: {
return Shapes[shape.type].getBounds(shape)
}
case ShapeType.Polyline: {
return Shapes[shape.type].getBounds(shape)
}
case ShapeType.Rectangle: {
return Shapes[shape.type].getBounds(shape)
}
default: {
return null
}
}
})
.filter(Boolean)
)
},
},
})

View file

@ -10,6 +10,8 @@ const { styled, global, css, theme, getCssString } = createCss({
brushStroke: "rgba(0,0,0,.5)",
hint: "rgba(66, 133, 244, 0.200)",
selected: "rgba(66, 133, 244, 1.000)",
bounds: "rgba(65, 132, 244, 1.000)",
boundsBg: "rgba(65, 132, 244, 0.100)",
},
space: {},
fontSizes: {
@ -33,6 +35,11 @@ const { styled, global, css, theme, getCssString } = createCss({
zIndices: {},
transitions: {},
},
utils: {
zStrokeWidth: () => (value: number) => ({
strokeWidth: `calc(${value}px / var(--camera-zoom))`,
}),
},
})
const light = theme({})

View file

@ -1,4 +1,4 @@
import { Data } from "types"
import { Data, Bounds } from "types"
import * as svg from "./svg"
import * as vec from "./vec"
@ -6,6 +6,39 @@ export function screenToWorld(point: number[], data: Data) {
return vec.sub(vec.div(point, data.camera.zoom), data.camera.point)
}
/**
* Get a bounding box that includes two bounding boxes.
* @param a Bounding box
* @param b Bounding box
* @returns
*/
export function getExpandedBounds(a: Bounds, b: Bounds) {
const minX = Math.min(a.minX, b.minX),
minY = Math.min(a.minY, b.minY),
maxX = Math.max(a.maxX, b.maxX),
maxY = Math.max(a.maxY, b.maxY),
width = Math.abs(maxX - minX),
height = Math.abs(maxY - minY)
return { minX, minY, maxX, maxY, width, height }
}
/**
* Get the common bounds of a group of bounds.
* @returns
*/
export function getCommonBounds(...b: Bounds[]) {
if (b.length < 2) return b[0]
let bounds = b[0]
for (let i = 1; i < b.length; i++) {
bounds = getExpandedBounds(bounds, b[i])
}
return bounds
}
export function getBoundsFromPoints(a: number[], b: number[]) {
const minX = Math.min(a[0], b[0])
const maxX = Math.max(a[0], b[0])

View file

@ -939,6 +939,18 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
"@emotion/is-prop-valid@^0.8.2":
version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==
dependencies:
"@emotion/memoize" "0.7.4"
"@emotion/memoize@0.7.4":
version "0.7.4"
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
"@hapi/accept@5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-5.0.1.tgz#068553e867f0f63225a506ed74e899441af53e10"
@ -3402,6 +3414,26 @@ fragment-cache@^0.2.1:
dependencies:
map-cache "^0.2.2"
framer-motion@^4.1.16:
version "4.1.16"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-4.1.16.tgz#dc715334847d0a146acf47f61019222d0d1c46c9"
integrity sha512-sEc3UI3oncwE+RUzdd86TxbmpEaX/Ki/T0AmFYSsbxEqGZ3feLvzGL7BJlkhERIyyuAC9+OzI4BnhJM0GSUAMA==
dependencies:
framesync "5.3.0"
hey-listen "^1.0.8"
popmotion "9.3.6"
style-value-types "4.1.4"
tslib "^2.1.0"
optionalDependencies:
"@emotion/is-prop-valid" "^0.8.2"
framesync@5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/framesync/-/framesync-5.3.0.tgz#0ecfc955e8f5a6ddc8fdb0cc024070947e1a0d9b"
integrity sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==
dependencies:
tslib "^2.1.0"
fs-extra@8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
@ -3652,6 +3684,11 @@ he@1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
hey-listen@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -5622,6 +5659,16 @@ pnp-webpack-plugin@1.6.4:
dependencies:
ts-pnp "^1.1.6"
popmotion@9.3.6:
version "9.3.6"
resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.3.6.tgz#b5236fa28f242aff3871b9e23721f093133248d1"
integrity sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw==
dependencies:
framesync "5.3.0"
hey-listen "^1.0.8"
style-value-types "4.1.4"
tslib "^2.1.0"
posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@ -6734,6 +6781,14 @@ strip-json-comments@^3.0.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
style-value-types@4.1.4:
version "4.1.4"
resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-4.1.4.tgz#80f37cb4fb024d6394087403dfb275e8bb627e75"
integrity sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==
dependencies:
hey-listen "^1.0.8"
tslib "^2.1.0"
styled-jsx@3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-3.3.2.tgz#2474601a26670a6049fb4d3f94bd91695b3ce018"
@ -7051,7 +7106,7 @@ tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.3:
tslib@^2.0.3, tslib@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==