├── .eslintrc.js
├── .gitignore
├── .vscode
└── launch.json
├── LICENSE
├── README.md
├── block.json
├── media
└── block.gif
├── package.json
└── src
├── Annotation.ts
├── DrawCanvas.tsx
├── Editor.tsx
├── Main.tsx
├── Settings.tsx
├── components
├── AttachmentPanel.tsx
├── CanvasHover.tsx
├── Confirm.tsx
├── ContextMenu.tsx
├── EmojiPicker.tsx
├── FabricCanvas.tsx
├── IconButton.tsx
├── LayersPanel.tsx
├── Layout.tsx
├── LeftPanel.tsx
├── PopoverButton.tsx
├── PropertiesInputs.tsx
├── PropertiesPanel.tsx
├── Setup.tsx
├── SidePanel.tsx
├── Snackbar.tsx
├── Toolbar.tsx
├── index.ts
└── keyboardShortcutsList.tsx
├── hooks
├── index.ts
├── useAnnotation.ts
├── useCursor.ts
├── useDeepCompareEffect.ts
├── useHotkeys.ts
├── useImage.ts
├── useLinkedRecords.ts
├── useRecords.ts
├── useResize.ts
├── useSettings.ts
└── useStyle.ts
├── index.tsx
├── styles.ts
├── tools
├── Arrow.ts
├── Circle.ts
├── Line.ts
├── Move.ts
├── Pencil.ts
├── Rectangle.ts
├── Select.ts
├── Tool.ts
├── ToolsList.tsx
└── index.ts
├── tsconfig.json
├── types
├── blocks.d.ts
├── fabric.d.ts
└── index.ts
└── utils
├── canvas.tsx
├── imageUtils.ts
├── index.ts
└── time.ts
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true,
5 | },
6 | extends: ['eslint:recommended', 'plugin:react/recommended'],
7 | globals: {
8 | Atomics: 'readonly',
9 | SharedArrayBuffer: 'readonly',
10 | },
11 | parser: '@typescript-eslint/parser',
12 | parserOptions: {
13 | ecmaFeatures: {
14 | jsx: true,
15 | },
16 | ecmaVersion: 2018,
17 | sourceType: 'module',
18 | },
19 | plugins: ['@typescript-eslint', 'react', 'react-hooks'],
20 | rules: {
21 | 'no-unused-vars': 'off',
22 | '@typescript-eslint/no-unused-vars': 'error',
23 | 'react/prop-types': 0,
24 | 'react-hooks/rules-of-hooks': 'error',
25 | 'react-hooks/exhaustive-deps': 'warn',
26 | },
27 | settings: {
28 | react: {
29 | version: 'detect',
30 | },
31 | },
32 | }
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /.airtableblocksrc.json
3 | /build
4 | /package-lock.json
5 | /.block
6 | .vscode
7 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "type": "firefox",
6 | "request": "launch",
7 | "name": "Debug in Firefox",
8 | "url": "https://airtable.com/",
9 | "profile": "dev-edition-default",
10 | "keepProfileChanges": true,
11 | "pathMappings": [
12 | {
13 | "url": "https://localhost:9000/__runFrame/build/development/transpiled/user/src",
14 | "path": "${workspaceFolder}/src"
15 | },
16 | ]
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Amr
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Annotate block
2 |
3 | 
4 | 
5 | 
6 | [](https://opensource.org/licenses/MIT)
7 |
8 | Annotate any image in your base with rectangles, circles, words, arrows, drawings, and emojis. Edit, scale, move and rename annotations at any time. Hover to see when and who created each annotation.
9 |
10 |
11 |
12 |
13 |
14 | ## How to remix this block
15 |
16 | 1. Create a new base (or you can use an existing base).
17 |
18 | 2. Create a new block in your base (see
19 | [Create a new block](https://airtable.com/developers/blocks/guides/hello-world-tutorial#create-a-new-block)),
20 | selecting "Remix from Github" as your template.
21 |
22 | 3. From the root of your new block, run `block run`.
23 |
--------------------------------------------------------------------------------
/block.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "1.0",
3 | "frontendEntry": "./src/index.tsx"
4 | }
--------------------------------------------------------------------------------
/media/block.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AmrEsyd/annotate/2cfdb58faead70c2bf01edd4b4d21b9351bbb3e5/media/block.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "annotate",
3 | "version": "2.0.0",
4 | "license": "MIT",
5 | "author": {
6 | "name": "Amr Elsayed",
7 | "url": "https://AmrEsyd.com"
8 | },
9 | "prettier": {
10 | "semi": false,
11 | "singleQuote": true
12 | },
13 | "dependencies": {
14 | "@airtable/blocks": "0.0.55",
15 | "@emotion/core": "^10.0.35",
16 | "@emotion/styled": "^10.0.27",
17 | "@sindresorhus/is": "^3.1.2",
18 | "emoji-mart": "^3.0.0",
19 | "fabric-pure-browser": "^4.1.0",
20 | "grapheme-splitter": "^1.0.4",
21 | "hotkeys-js": "^3.8.1",
22 | "lodash": "^4.17.20",
23 | "lz-string": "^1.4.4",
24 | "pretty-ms": "^7.0.0",
25 | "rc-notification": "^4.4.0",
26 | "react": "^16.13.1",
27 | "react-dom": "^16.13.1",
28 | "react-fast-compare": "^3.2.0",
29 | "react-icons": "^3.11.0",
30 | "tslib": "^2.0.1",
31 | "use-debounce": "^4.0.0"
32 | },
33 | "devDependencies": {
34 | "@babel/core": "^7.11.6",
35 | "@babel/preset-env": "^7.11.5",
36 | "@types/emoji-mart": "^3.0.2",
37 | "@types/fabric": "^3.6.8",
38 | "@types/lz-string": "^1.3.34",
39 | "@types/node": "^14.6.3",
40 | "@types/react-virtualized": "^9.21.10",
41 | "@types/react-virtualized-auto-sizer": "^1.0.0",
42 | "@typescript-eslint/eslint-plugin": "^4.0.1",
43 | "@typescript-eslint/parser": "^4.0.1",
44 | "eslint": "^7.8.1",
45 | "eslint-plugin-react": "^7.20.6",
46 | "eslint-plugin-react-hooks": "^4.1.0",
47 | "prettier": "^2.1.1",
48 | "typescript": "^4.0.2"
49 | },
50 | "scripts": {
51 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src",
52 | "prettier": "prettier --write src",
53 | "typecheck": "tsc -w -p ./src --noEmit"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Annotation.ts:
--------------------------------------------------------------------------------
1 | import lzString from 'lz-string'
2 | import isEqual from 'react-fast-compare'
3 |
4 | import { Field, Record, Table } from '@airtable/blocks/models'
5 | import is from '@sindresorhus/is'
6 |
7 | import { snackbar } from './components'
8 | import { Attachment } from './hooks'
9 |
10 | const textFieldTypes = ['multilineText', 'richText', 'singleLineText']
11 |
12 | export class Annotation {
13 | record
14 |
15 | constructor(
16 | record: Record | null,
17 | private attachment: Attachment,
18 | public table: Table | null,
19 | private storageField: Field
20 | ) {
21 | this.record = record?.isDeleted ? null : record
22 | this.table = this.table?.isDeleted ? null : this.table
23 | }
24 |
25 | get name() {
26 | return this.attachment.filename
27 | }
28 |
29 | get storeUpdatePermission() {
30 | if (this.record && this.storageField && this.table) {
31 | return this.table.checkPermissionsForUpdateRecord(this.record, {
32 | [this.storageField.id]: undefined,
33 | })
34 | }
35 | return null
36 | }
37 |
38 | get imageUrl() {
39 | return this.attachment?.thumbnails?.full?.url || ''
40 | }
41 |
42 | get store() {
43 | const compressedValue = this.storageField
44 | ? this.record?.getCellValue(this.storageField)
45 | : null
46 |
47 | const storeString = is.string(compressedValue)
48 | ? lzString.decompressFromBase64(compressedValue) || undefined
49 | : undefined
50 |
51 | return storeString
52 | }
53 |
54 | get isDeleted() {
55 | if (
56 | !this.record ||
57 | !this.table ||
58 | !this.storageField ||
59 | this.record.isDeleted ||
60 | this.table.isDeleted ||
61 | this.storageField.isDeleted
62 | ) {
63 | return true
64 | }
65 | return false
66 | }
67 |
68 | private createStoreRecord(newStore: string) {
69 | if (!this.table) return
70 | const primaryFieldId = this.table.primaryField.id
71 |
72 | const newCompressedStore = lzString.compressToBase64(newStore)
73 | let newAnnotationRecordValue = {
74 | [primaryFieldId]: this.attachment.id,
75 | [this.storageField.id]: newCompressedStore,
76 | }
77 |
78 | const permissionsToCreateRecord = this.table.checkPermissionsForCreateRecord(
79 | newAnnotationRecordValue
80 | )
81 |
82 | if (!permissionsToCreateRecord.hasPermission)
83 | return snackbar(permissionsToCreateRecord.reasonDisplayString)
84 |
85 | return this.table.createRecordAsync(newAnnotationRecordValue)
86 | }
87 |
88 | updateStore(newStore: string | null) {
89 | if (!newStore) {
90 | if (this.record && this.table?.hasPermissionToDeleteRecord(this.record)) {
91 | this.table?.deleteRecordAsync(this.record)
92 | }
93 | return
94 | }
95 | if (!this.record) return this.createStoreRecord(newStore)
96 |
97 | if (!this.table || !this.storageField) return
98 |
99 | const newCompressedStore = lzString.compressToBase64(newStore)
100 | const oldCompressedStore = this.storageField
101 | ? this.record?.getCellValue(this.storageField)
102 | : null
103 |
104 | const isNotEqual = !isEqual(newCompressedStore, oldCompressedStore)
105 |
106 | if (
107 | this.storeUpdatePermission?.hasPermission &&
108 | isNotEqual &&
109 | textFieldTypes.includes(this.storageField.type)
110 | ) {
111 | return this.table
112 | .updateRecordAsync(this.record, {
113 | [this.storageField.id]: newCompressedStore,
114 | })
115 | .catch((error: Error) => {
116 | snackbar(error.message)
117 | })
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/DrawCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import clamp from 'lodash/clamp'
3 | import throttle from 'lodash/throttle'
4 | import React, { useCallback, useContext, useEffect, useRef } from 'react'
5 | import { FiZoomIn, FiZoomOut } from 'react-icons/fi'
6 | import { useDebouncedCallback } from 'use-debounce'
7 |
8 | import { Box, Loader, useSession } from '@airtable/blocks/ui'
9 |
10 | import { FabricCanvas, IconButton, shortcutsList } from './components/'
11 | import { EditorContext } from './Editor'
12 | import { useHotkeys, useImage, useResize } from './hooks'
13 | import { Move, Select } from './tools'
14 | import {
15 | deleteActiveObjects,
16 | getCanvasJson,
17 | resetViewport,
18 | updateAndScaleImage,
19 | } from './utils'
20 |
21 | const MAX_ZOOM = 10
22 | const MIN_ZOOM = 0.98
23 |
24 | type DrawCanvasProps = {
25 | store?: string
26 | updateStore?: (newDrawLayerJson: string | null) => void
27 | imageUrl: string
28 | onSelection: (e: fabric.IEvent) => unknown
29 | }
30 |
31 | export const DrawCanvas: React.FC = (props) => {
32 | const { imageUrl, store, updateStore, onSelection } = props
33 |
34 | const {
35 | canvas,
36 | setCanvas,
37 | activeTool,
38 | canvasContainerRef,
39 | handleToolChange,
40 | } = useContext(EditorContext)
41 |
42 | const [image] = useImage(imageUrl)
43 | const session = useSession()
44 | const zoomRef = useRef(MIN_ZOOM)
45 |
46 | const setZoom = useCallback(
47 | (value: number, zoomToPoint?: fabric.Point) => {
48 | const container = canvasContainerRef.current
49 | const zoom = clamp(zoomRef.current + value, MIN_ZOOM, MAX_ZOOM)
50 | zoomRef.current = zoom
51 | if (container)
52 | updateAndScaleImage({ canvas, image, container, zoom, zoomToPoint })
53 | },
54 | [canvas, image, canvasContainerRef]
55 | )
56 |
57 | const updateImage = () => {
58 | const container = canvasContainerRef.current
59 | if (container)
60 | updateAndScaleImage({ canvas, image, container, zoom: zoomRef.current })
61 | }
62 |
63 | useEffect(updateImage, [image, canvas, canvasContainerRef])
64 | useResize(updateImage, [image, canvas, canvasContainerRef])
65 |
66 | useHotkeys(
67 | shortcutsList.deleteShape.shortcuts,
68 | () => canvas && deleteActiveObjects(canvas),
69 | [canvas]
70 | )
71 |
72 | useEffect(() => {
73 | if (!canvas) return
74 | const currentCanvasValue = getCanvasJson(canvas)
75 | if (store && store !== currentCanvasValue) {
76 | canvas.loadFromJSON(store, () => {
77 | canvas.backgroundImage = image || undefined
78 | canvas.requestRenderAll()
79 | })
80 | }
81 | }, [canvas, store, image])
82 |
83 | const [saveToAirtable] = useDebouncedCallback(() => {
84 | if (!canvas) return
85 | const newStorageValue = getCanvasJson(canvas)
86 | if (updateStore && newStorageValue !== store) {
87 | updateStore(newStorageValue)
88 | }
89 | }, 200)
90 |
91 | useEffect(() => {
92 | if (!canvas) return
93 | const isCursor = activeTool instanceof Move || activeTool instanceof Select
94 |
95 | const handleWheel = throttle(
96 | (event: WheelEvent, pointer?: fabric.Point) => {
97 | // ctrlKey is for "Pinch to zoom" using trackpad
98 | if (event.metaKey || event.ctrlKey) {
99 | setZoom(-event.deltaY / 15, pointer)
100 | } else {
101 | canvas.relativePan(
102 | new fabric.Point(event.deltaX * -2, event.deltaY * -2)
103 | )
104 | resetViewport(canvas)
105 | }
106 | },
107 | 30
108 | )
109 |
110 | const handleContainerWheel = (event: WheelEvent) => {
111 | event.preventDefault()
112 | event.stopPropagation()
113 | handleWheel(event)
114 | }
115 |
116 | const containerElement = canvasContainerRef.current
117 | containerElement?.addEventListener('wheel', handleContainerWheel)
118 |
119 | const events = {
120 | 'after:render': saveToAirtable,
121 | 'mouse:up': (e: fabric.IEvent) => {
122 | activeTool?.onMouseUp?.(e)
123 | if (!isCursor) {
124 | handleToolChange(null)
125 | }
126 | },
127 | 'mouse:wheel': (event: fabric.IEvent) => {
128 | const wheelEvent = event.e as WheelEvent
129 | wheelEvent.preventDefault()
130 | wheelEvent.stopPropagation()
131 | handleWheel(wheelEvent, event.pointer)
132 | },
133 | 'mouse:move': throttle((event: fabric.IEvent) => {
134 | activeTool?.onMouseMove?.(event)
135 | }, 30),
136 | 'selection:created': (e: fabric.IEvent) => onSelection(e),
137 | 'selection:updated': (e: fabric.IEvent) => onSelection(e),
138 | 'selection:cleared': (e: fabric.IEvent) => onSelection(e),
139 | 'mouse:down': (event: fabric.IEvent) => {
140 | if (event.button === 1 /* left click */) {
141 | const noActiveObjects = canvas.getActiveObjects().length === 0
142 | if (!event.target && noActiveObjects) {
143 | activeTool?.onMouseDown?.(event)
144 | }
145 | }
146 | },
147 | 'object:added': (event: fabric.IEvent) => {
148 | const object = event.target
149 | if (object && !object.createdTime && !object.createdBy) {
150 | object.createdBy = session.currentUser?.id
151 | object.modifiedBy = session.currentUser?.id
152 | object.createdTime = Date.now()
153 | object.modifiedTime = Date.now()
154 | }
155 | },
156 | 'state:modified': (event: fabric.IEvent) => {
157 | const object = event.target
158 | if (object) {
159 | object.modifiedBy = session.currentUser?.id
160 | object.modifiedTime = Date.now()
161 | }
162 | saveToAirtable()
163 | },
164 | 'object:modified': (event: fabric.IEvent) => {
165 | const object = event.target
166 | if (object) {
167 | object.modifiedBy = session.currentUser?.id
168 | object.modifiedTime = Date.now()
169 | }
170 | },
171 | }
172 |
173 | canvas.on(events)
174 | return () => {
175 | Object.entries(events).forEach(([event, func]) => {
176 | canvas.off(event, func)
177 | })
178 | containerElement?.removeEventListener('wheel', handleContainerWheel)
179 | }
180 | }, [
181 | canvasContainerRef,
182 | setZoom,
183 | activeTool,
184 | canvas,
185 | saveToAirtable,
186 | onSelection,
187 | handleToolChange,
188 | session.currentUser?.id,
189 | ])
190 |
191 | return imageUrl && !image ? (
192 |
193 | ) : (
194 |
195 |
196 |
206 | }
211 | onClick={() => {
212 | setZoom(+0.1)
213 | }}
214 | />
215 | }
220 | onClick={() => {
221 | setZoom(-0.1)
222 | }}
223 | />
224 |
225 |
226 | )
227 | }
228 |
--------------------------------------------------------------------------------
/src/Editor.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions } from 'fabric/fabric-impl'
3 | import React, {
4 | useCallback,
5 | useContext,
6 | useEffect,
7 | useRef,
8 | useState,
9 | } from 'react'
10 |
11 | import { session } from '@airtable/blocks'
12 | import { Record } from '@airtable/blocks/models'
13 | import { Box } from '@airtable/blocks/ui'
14 | import is from '@sindresorhus/is/dist'
15 |
16 | import {
17 | CanvasContainer,
18 | CanvasHover,
19 | confirmDialog,
20 | ContextMenu,
21 | ContextMenuEvent,
22 | EditorContainer,
23 | IconButton,
24 | LeftPanel,
25 | MenuOption,
26 | PropertiesPanel,
27 | shortcutsList,
28 | Toolbar,
29 | } from './components'
30 | import { DrawCanvas } from './DrawCanvas'
31 | import {
32 | Attachment,
33 | defaultStyle,
34 | useAnnotation,
35 | useCursor,
36 | useDeepCompareEffect,
37 | useHotkeys,
38 | useRecordsAttachments,
39 | useStyle,
40 | } from './hooks'
41 | import { BlockContext } from './Main'
42 | import { CanvasTool, Select } from './tools'
43 | import { downloadCanvasAsImage } from './utils'
44 |
45 | type EditorContextType = {
46 | activeTool: CanvasTool | null
47 | handleToolChange: (newTool: CanvasTool | null) => void
48 | canvas: fabric.Canvas | null
49 | setCanvas: (canvas: fabric.Canvas | null) => unknown
50 | canvasContainerRef: React.RefObject
51 | setContextMenu: (menuEvent: ContextMenuEvent | null) => unknown
52 | }
53 |
54 | const FEEDBACK_FORM_LINK = `https://airtable.com/shrXM6X6edPU8MhgH`
55 |
56 | export const EditorContext = React.createContext({} as any)
57 |
58 | export const Editor: React.FC = () => {
59 | const { showKeyboardShortcuts } = useContext(BlockContext)
60 | const [canvas, setCanvas] = useState(null)
61 | const [activeTool, setActiveTool] = useState(null)
62 | const [showSidebar, setShowSidebar] = useState(false)
63 | const [showPropertiesPanel, setShowPropertiesPanel] = useState(false)
64 | const [activeRecords, setActiveRecords] = useState(null)
65 | const [contextMenuEvent, setContextMenu] = useState(
66 | null
67 | )
68 | const cursor = useCursor()
69 |
70 | const [
71 | activeAnnotationAttachment,
72 | setActiveAnnotation,
73 | ] = useState(null)
74 | const canvasContainerRef = useRef(null)
75 |
76 | const {
77 | activeLayer,
78 | activeStyle,
79 | updateUserStyles,
80 | setSelectedLayer,
81 | } = useStyle(activeTool, canvas)
82 |
83 | const activeAnnotation = useAnnotation(activeAnnotationAttachment)
84 |
85 | const activeTable = activeRecords?.[0]?.parentTable
86 | const attachments = useRecordsAttachments(activeTable, activeRecords)
87 |
88 | useDeepCompareEffect(() => {
89 | if (is.nonEmptyArray(cursor.selectedRecords))
90 | setActiveRecords(cursor.selectedRecords)
91 | }, [cursor.selectedRecords])
92 |
93 | useEffect(() => {
94 | const firstAttachment = attachments?.[0]
95 | if (firstAttachment) {
96 | setActiveAnnotation(firstAttachment)
97 | } else {
98 | setActiveAnnotation(null)
99 | }
100 | }, [attachments])
101 |
102 | const canvasMenuOptions: MenuOption[] = [
103 | {
104 | label: 'Download image',
105 | icon: 'download',
106 | shortcutId: 'download',
107 | onClick: () =>
108 | canvas &&
109 | downloadCanvasAsImage(canvas, activeAnnotationAttachment?.filename),
110 | },
111 | {
112 | icon: 'laptop',
113 | label: 'Keyboard shortcuts',
114 | onClick: () => showKeyboardShortcuts?.(),
115 | },
116 | {
117 | icon: 'form',
118 | label: 'feedback',
119 | onClick() {
120 | const userEmail = session.currentUser?.email
121 | window.open(
122 | userEmail
123 | ? `${FEEDBACK_FORM_LINK}?prefill_Email=${userEmail}`
124 | : FEEDBACK_FORM_LINK
125 | )
126 | },
127 | },
128 | {
129 | label: 'Delete all layers',
130 | icon: 'trash',
131 | onClick: () =>
132 | confirmDialog({
133 | title: `Are you sure you want to delete all layers on ${activeAnnotation?.name}?`,
134 | isConfirmActionDangerous: true,
135 | onConfirm() {
136 | canvas?.remove(...canvas.getObjects())
137 | },
138 | }),
139 | },
140 | ]
141 |
142 | const handleToolChange = useCallback(
143 | (tool: CanvasTool | null) => {
144 | if (!canvas) return
145 | tool ||= new Select(canvas)
146 | const props: IObjectOptions = {
147 | ...defaultStyle,
148 | ...activeStyle,
149 | }
150 |
151 | if (activeTool?.name !== tool.name) {
152 | setActiveTool(tool)
153 | tool.configureCanvas?.(props)
154 | }
155 | },
156 | [activeTool?.name, canvas, activeStyle]
157 | )
158 |
159 | useHotkeys(
160 | shortcutsList.download.shortcuts,
161 | () =>
162 | canvas &&
163 | downloadCanvasAsImage(canvas, activeAnnotationAttachment?.filename),
164 | [canvas, downloadCanvasAsImage, activeAnnotationAttachment]
165 | )
166 |
167 | useEffect(() => {
168 | const permission = activeAnnotation?.storeUpdatePermission
169 | if (permission && !permission.hasPermission) {
170 | confirmDialog({
171 | title: 'Your changes will NOT be saved to the base',
172 | body: permission.reasonDisplayString,
173 | })
174 | }
175 | // eslint-disable-next-line react-hooks/exhaustive-deps
176 | }, [activeAnnotation?.storeUpdatePermission?.hasPermission])
177 |
178 | let errorMessage: string | React.ReactNode = ''
179 |
180 | if (!activeRecords || activeRecords.length === 0) {
181 | errorMessage = 'Select a record to view the attachments annotations'
182 | } else if (!attachments || attachments?.length === 0) {
183 | if (activeRecords.length === 1) {
184 | if (!activeRecords || activeRecords[0]?.isDeleted) {
185 | errorMessage = 'Someone deleted the selected record'
186 | } else {
187 | errorMessage = 'Select a record to view the attachments annotations'
188 | }
189 | } else {
190 | errorMessage = 'No attachments in selected records.'
191 | }
192 | }
193 |
194 | const ToolbarButtons = [
195 | {
202 | setShowSidebar(!showSidebar)
203 | setTimeout(() => window.dispatchEvent(new Event('resize')))
204 | }}
205 | />,
206 | ]
207 |
208 | const ToolbarButtonsRight = [
209 | {
216 | setShowPropertiesPanel(!showPropertiesPanel)
217 | setTimeout(() => window.dispatchEvent(new Event('resize')))
218 | }}
219 | />,
220 | ]
221 |
222 | return (
223 |
233 |
234 |
235 |
240 |
250 | {showSidebar && (
251 |
256 | )}
257 | {
259 | e.preventDefault()
260 | setContextMenu({ position: { x: e.clientX, y: e.clientY } })
261 | }}
262 | ref={canvasContainerRef}
263 | >
264 |
268 |
269 | {activeAnnotation && !errorMessage ? (
270 |
276 | activeAnnotation.updateStore(newStore)
277 | }
278 | />
279 | ) : (
280 |
281 | {errorMessage}
282 |
283 | )}
284 |
285 | {showPropertiesPanel && (
286 |
291 | )}
292 |
293 |
294 |
295 |
296 | )
297 | }
298 |
--------------------------------------------------------------------------------
/src/Main.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { Box, useBase, useSettingsButton } from '@airtable/blocks/ui'
4 |
5 | import { KeyboardShortcutsList, Setup, shortcutsList } from './components'
6 | import { Editor } from './Editor'
7 | import { useHotkeys, useSettings } from './hooks'
8 | import { Settings } from './Settings'
9 |
10 | type BlockContextType = {
11 | showSettings: () => unknown
12 | showKeyboardShortcuts: () => unknown
13 | }
14 |
15 | export const BlockContext = React.createContext(null as any)
16 |
17 | export function Main() {
18 | const base = useBase()
19 | const [shouldRenderSettings, setShouldRenderSettings] = useState(false)
20 | const [
21 | shouldRenderKeyboardShortcuts,
22 | setShouldRenderKeyboardShortcuts,
23 | ] = useState(false)
24 | const { annotationsTableId, storageFieldId } = useSettings()
25 |
26 | const annotationsTable = annotationsTableId
27 | ? base.getTableByIdIfExists(annotationsTableId)
28 | : null
29 |
30 | const storageField = storageFieldId
31 | ? annotationsTable?.getFieldByIdIfExists(storageFieldId)
32 | : null
33 |
34 | useSettingsButton(() => {
35 | setShouldRenderSettings(!shouldRenderSettings)
36 | })
37 |
38 | useHotkeys(
39 | shortcutsList.keyboardShortcuts.shortcuts,
40 | () => {
41 | setShouldRenderKeyboardShortcuts(true)
42 | },
43 | [setShouldRenderKeyboardShortcuts]
44 | )
45 |
46 | let render
47 | if (
48 | !annotationsTable ||
49 | annotationsTable.isDeleted ||
50 | !storageField ||
51 | storageField.isDeleted
52 | ) {
53 | render =
54 | } else {
55 | render =
56 | }
57 |
58 | const contextValue: BlockContextType = {
59 | showSettings: () => setShouldRenderSettings(true),
60 | showKeyboardShortcuts: () => setShouldRenderKeyboardShortcuts(true),
61 | }
62 |
63 | return (
64 |
65 |
66 | {shouldRenderKeyboardShortcuts && (
67 | setShouldRenderKeyboardShortcuts(false)}
69 | />
70 | )}
71 | {shouldRenderSettings && (
72 | setShouldRenderSettings(false)} />
73 | )}
74 | {render}
75 |
76 |
77 | )
78 | }
79 |
--------------------------------------------------------------------------------
/src/Settings.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { FieldType } from '@airtable/blocks/models'
4 | import {
5 | Dialog,
6 | FieldPickerSynced,
7 | FormField,
8 | Heading,
9 | TablePickerSynced,
10 | useBase,
11 | useSynced,
12 | } from '@airtable/blocks/ui'
13 | import is from '@sindresorhus/is'
14 |
15 | export const globalConfigKeys = {
16 | annotationsTableId: 'annotationsTableId',
17 | storageFieldId: 'storageFieldId',
18 | } as const
19 |
20 | export const Settings: React.FC<{ onClose: () => unknown }> = ({ onClose }) => {
21 | const base = useBase()
22 | const [annotationsTableId] = useSynced(globalConfigKeys.annotationsTableId)
23 | const annotationsTable = is.string(annotationsTableId)
24 | ? base.getTableByIdIfExists(annotationsTableId)
25 | : null
26 |
27 | return (
28 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/AttachmentPanel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import isDeepEqual from 'react-fast-compare'
3 |
4 | import { Box, Button, expandRecord, Loader, Text } from '@airtable/blocks/ui'
5 | import styled from '@emotion/styled'
6 |
7 | import { Attachment, useAnnotation, useImage } from '../hooks'
8 | import { updateAndScaleImage } from '../utils'
9 | import { FabricStaticCanvas, IconButton } from './index'
10 |
11 | type AttachmentProps = {
12 | attachment: Attachment
13 | }
14 |
15 | export const ImageContainer = styled(Button)`
16 | margin: 2.5%;
17 | padding: 0.2rem;
18 | height: 150px;
19 | width: 95%;
20 | flex-direction: column;
21 | overflow: hidden;
22 |
23 | & span {
24 | display: block;
25 | width: 100%;
26 | }
27 |
28 | & img {
29 | max-width: 100%;
30 | max-height: 100%;
31 | }
32 | `
33 |
34 | const _AttachmentCanvas: React.FC = (props) => {
35 | const { attachment: record } = props
36 | const staticCanvasRef = useRef(null)
37 | const annotation = useAnnotation(record)
38 | const [image] = useImage(annotation?.imageUrl)
39 |
40 | useEffect(() => {
41 | const updateImage = (canvas: fabric.StaticCanvas) => {
42 | if (image) {
43 | canvas.backgroundImage = image
44 | }
45 | updateAndScaleImage({
46 | canvas,
47 | image,
48 | dimensions: {
49 | width: 180,
50 | height: 100,
51 | },
52 | })
53 | }
54 |
55 | const StaticCanvas = staticCanvasRef.current
56 |
57 | if (StaticCanvas) {
58 | updateImage(StaticCanvas)
59 |
60 | if (annotation?.store) {
61 | StaticCanvas?.loadFromJSON(annotation.store, () => {
62 | updateImage(StaticCanvas)
63 | })
64 | }
65 | }
66 | }, [annotation?.store, image])
67 |
68 | return image ? (
69 |
70 |
71 |
72 | ) : (
73 |
74 | )
75 | }
76 |
77 | const AttachmentCanvas = React.memo(_AttachmentCanvas, isDeepEqual)
78 |
79 | type AttachmentPreviewProps = {
80 | attachment: Attachment
81 | onClickAttachment: (attachment: Attachment) => void
82 | }
83 |
84 | const AttachmentPreview: React.FC = ({
85 | attachment,
86 | onClickAttachment,
87 | }) => {
88 | return (
89 | onClickAttachment?.(attachment)}
93 | style={{ overflow: 'hidden' }}
94 | icon={
95 |
102 |
103 |
104 | }
105 | >
106 |
107 |
116 | {attachment?.filename}
117 |
118 |
119 | attachment && expandRecord(attachment.record)}
124 | />
125 |
126 |
127 |
128 | )
129 | }
130 |
131 | type AttachmentPanelProps = {
132 | attachments: Attachment[]
133 | onClickAttachment: (attachment: Attachment) => void
134 | }
135 |
136 | export const _AttachmentPanel: React.FC = (props) => {
137 | const { attachments, onClickAttachment } = props
138 |
139 | return (
140 |
141 | {attachments.map((attachment) => (
142 |
147 | ))}
148 |
149 | )
150 | }
151 |
152 | export const AttachmentPanel = React.memo(_AttachmentPanel, isDeepEqual)
153 |
--------------------------------------------------------------------------------
/src/components/CanvasHover.tsx:
--------------------------------------------------------------------------------
1 | import throttle from 'lodash/throttle'
2 | import truncate from 'lodash/truncate'
3 | import React, { useContext, useEffect, useRef, useState } from 'react'
4 |
5 | import { CollaboratorData } from '@airtable/blocks/dist/types/src/types/collaborator'
6 | import {
7 | Box,
8 | CollaboratorToken,
9 | Icon,
10 | Text,
11 | useBase,
12 | } from '@airtable/blocks/ui'
13 |
14 | import { EditorContext } from '../Editor'
15 | import { getTimeFromNow, truncateCollaborator } from '../utils'
16 |
17 | /** Max tooltip width */
18 | const TOOLTIP_WIDTH = 150
19 | /** Max shape name letters length */
20 | const SHAPE_NAME_LENGTH = 48
21 | /** Max collaborator name letters length */
22 | const COLLABORATOR_LENGTH = 12
23 |
24 | export const CanvasHover: React.FC = () => {
25 | const base = useBase()
26 | const boxRef = useRef(null)
27 | const { canvas, canvasContainerRef } = useContext(EditorContext)
28 |
29 | const [cursorPosition, setCursorPosition] = useState<{
30 | x: number
31 | y: number
32 | } | null>(null)
33 |
34 | const [objectInfo, setObjectInfo] = useState<{
35 | name?: string
36 | createdBy?: CollaboratorData | null
37 | modifiedBy?: CollaboratorData | null
38 | modifiedTime?: number | null
39 | createdTime?: number | null
40 | } | null>(null)
41 |
42 | useEffect(() => {
43 | if (!canvas) return
44 |
45 | const handleMouseOver = (event: fabric.IEvent) => {
46 | const object = event.target
47 | if (object && object.type !== 'activeSelection') {
48 | const modifiedByCollaborator = base.getCollaboratorByIdIfExists(
49 | object?.modifiedBy!
50 | )
51 | const createdByCollaborator = base.getCollaboratorByIdIfExists(
52 | object?.createdBy!
53 | )
54 |
55 | setObjectInfo({
56 | name: truncate(object.name, { length: SHAPE_NAME_LENGTH }),
57 | modifiedTime: object.modifiedTime,
58 | createdTime: object.createdTime,
59 | createdBy:
60 | createdByCollaborator &&
61 | truncateCollaborator(createdByCollaborator, COLLABORATOR_LENGTH),
62 | modifiedBy:
63 | modifiedByCollaborator &&
64 | truncateCollaborator(modifiedByCollaborator, COLLABORATOR_LENGTH),
65 | })
66 | } else if (objectInfo) {
67 | setObjectInfo(null)
68 | setCursorPosition(null)
69 | }
70 | }
71 |
72 | const handleMouseMove = throttle((event: fabric.IEvent) => {
73 | const mouseEvent = event.e instanceof MouseEvent ? event.e : null
74 | const object = event.target
75 |
76 | if (mouseEvent && object && objectInfo) {
77 | const container = canvasContainerRef.current?.getBoundingClientRect()
78 | const box = boxRef.current
79 |
80 | const boxWidth = (box?.clientWidth || 0) + 10
81 | const boxHeight = (box?.clientHeight || 0) + 10
82 |
83 | if (!container) return
84 |
85 | const shouldFlipX =
86 | container.left + container.width - boxWidth - mouseEvent.clientX < 0
87 | const shouldFlipY =
88 | container.top + container.height - boxHeight - mouseEvent.clientY < 0
89 |
90 | setCursorPosition({
91 | x: shouldFlipX
92 | ? mouseEvent.clientX - boxWidth
93 | : mouseEvent.clientX + 10,
94 | y: shouldFlipY
95 | ? mouseEvent.clientY - boxHeight
96 | : mouseEvent.clientY + 10,
97 | })
98 | } else {
99 | handleMouseOver(event)
100 | }
101 | }, 50)
102 |
103 | const handleMouseOut = () => {
104 | setObjectInfo(null)
105 | setCursorPosition(null)
106 | }
107 |
108 | canvas.on({
109 | 'mouse:over': handleMouseOver,
110 | 'mouse:move': handleMouseMove,
111 | 'mouse:wheel': handleMouseMove,
112 | 'mouse:out': handleMouseOut,
113 | })
114 |
115 | return () => {
116 | canvas?.off('mouse:over', handleMouseOver)
117 | canvas?.off('mouse:move', handleMouseMove)
118 | canvas?.off('mouse:wheel', handleMouseMove)
119 | canvas?.off('mouse:out', handleMouseOut)
120 | }
121 | }, [base, canvas, objectInfo, canvasContainerRef])
122 |
123 | if (!objectInfo) return <>>
124 |
125 | const { name, modifiedBy, createdBy, modifiedTime, createdTime } = objectInfo
126 |
127 | return cursorPosition && objectInfo ? (
128 |
142 | {name && (
143 |
144 |
145 |
146 | {name}
147 |
148 |
149 | )}
150 | {modifiedTime && (
151 |
152 |
153 |
154 | {modifiedTime !== createdTime ? 'Edited' : 'Created'}{' '}
155 | {getTimeFromNow(modifiedTime)}
156 |
157 |
158 | )}
159 | {modifiedBy && modifiedBy.id !== createdBy?.id && (
160 |
161 |
162 |
163 |
164 | )}
165 | {createdBy && (
166 |
167 |
168 |
169 |
170 | )}
171 |
172 | ) : (
173 | <>>
174 | )
175 | }
176 |
--------------------------------------------------------------------------------
/src/components/Confirm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 |
4 | import { ConfirmationDialog } from '@airtable/blocks/ui'
5 |
6 | type confirmProps = { title: string } & Partial
7 |
8 | export function confirmDialog(args: confirmProps) {
9 | const { onCancel, onConfirm, ...props } = args
10 | const containerElement = document.createElement('div')
11 | document.body.appendChild(containerElement)
12 |
13 | const close = () => {
14 | const unmounted = ReactDOM.unmountComponentAtNode(containerElement)
15 | if (containerElement.parentNode && unmounted) {
16 | containerElement.parentNode.removeChild(containerElement)
17 | }
18 | }
19 |
20 | setTimeout(() => {
21 | ReactDOM.render(
22 | {
24 | close()
25 | onCancel?.()
26 | }}
27 | onConfirm={() => {
28 | close()
29 | onConfirm?.()
30 | }}
31 | {...props}
32 | />,
33 | containerElement
34 | )
35 | })
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ContextMenu.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import React, { useContext, useEffect, useRef, useState } from 'react'
3 |
4 | import {
5 | FitInWindowMode,
6 | PopoverPlacements,
7 | } from '@airtable/blocks/dist/types/src/ui/popover'
8 | import {
9 | Box,
10 | Button,
11 | CollaboratorToken,
12 | Dialog,
13 | FormField,
14 | Icon,
15 | Input,
16 | Popover,
17 | Text,
18 | useBase,
19 | } from '@airtable/blocks/ui'
20 |
21 | import { Menu, MenuOption, renderOptions } from '../components'
22 | import { EditorContext } from '../Editor'
23 | import {
24 | deleteActiveObjects,
25 | getTimeFromNow,
26 | truncateCollaborator,
27 | } from '../utils'
28 | import { getLayerNameAndIcon } from './LayersPanel'
29 |
30 | /** Max menu width */
31 | const MENU_WIDTH = 140
32 | /** Max collaborator name letters length */
33 | const COLLABORATOR_LENGTH = 12
34 |
35 | const RenameDialog: React.FC<{
36 | object: fabric.Object
37 | onClose: () => unknown
38 | }> = ({ object, onClose }) => {
39 | const { name, icon } = getLayerNameAndIcon(object)
40 | const [value, setValue] = useState(name || '')
41 |
42 | const save = () => {
43 | if (value !== name) {
44 | object.set('name', value)
45 | object.canvas?.fire('state:modified', { target: object })
46 | }
47 | onClose()
48 | }
49 |
50 | return (
51 |
62 | )
63 | }
64 |
65 | export type ContextMenuEvent = {
66 | position: { x: number; y: number }
67 | object?: fabric.Object
68 | }
69 |
70 | type ContextMenuProps = {
71 | menuEvent: ContextMenuEvent | null
72 | menuOptions: MenuOption[]
73 | }
74 |
75 | export const ContextMenu: React.FC = ({
76 | menuEvent,
77 | menuOptions,
78 | }) => {
79 | const base = useBase()
80 | const menuRef = useRef(null)
81 | const { setContextMenu, canvas, canvasContainerRef } = useContext(
82 | EditorContext
83 | )
84 | const [renameObject, setRenameObject] = useState(null)
85 |
86 | useEffect(() => {
87 | const hideMenu = (event: MouseEvent) => {
88 | const menu = menuRef.current
89 | if (event.target instanceof HTMLCanvasElement) return
90 |
91 | if (
92 | menu &&
93 | !menu?.contains(event.target as any) &&
94 | event.button !== 3 /* 3 is a right click */
95 | ) {
96 | setContextMenu(null)
97 | }
98 | }
99 |
100 | window.addEventListener('mousedown', hideMenu)
101 | return () => {
102 | window.removeEventListener('mousedown', hideMenu)
103 | }
104 | }, [renameObject, canvasContainerRef, setContextMenu])
105 |
106 | useEffect(() => {
107 | const showMenu = (event: MouseEvent) => {
108 | if (canvasContainerRef.current === event.target) {
109 | event.stopPropagation()
110 | event.preventDefault()
111 | setContextMenu({ position: { x: event.clientX, y: event.clientY } })
112 | }
113 | }
114 |
115 | window.addEventListener('contextmenu', showMenu)
116 | return () => {
117 | window.removeEventListener('contextmenu', showMenu)
118 | }
119 | }, [renameObject, canvasContainerRef, setContextMenu])
120 |
121 | useEffect(() => {
122 | const renderContextMenu = (e: fabric.IEvent) => {
123 | if (e.button !== 3 /* 3 is a right click */) {
124 | setContextMenu(null)
125 | return
126 | }
127 | setContextMenu({ position: e.e as MouseEvent, object: e.target })
128 | }
129 |
130 | if (canvas) {
131 | canvas.on('mouse:down', renderContextMenu)
132 | return () => {
133 | canvas.off('mouse:down', renderContextMenu)
134 | }
135 | }
136 | }, [canvas, setContextMenu])
137 |
138 | if (renameObject) {
139 | return (
140 | setRenameObject(null)}
143 | />
144 | )
145 | } else if (!menuEvent) {
146 | return <>>
147 | }
148 |
149 | let menu: React.ReactElement
150 | const position = menuEvent.position
151 | const object = menuEvent.object
152 |
153 | const preventDefaultMenu = (
154 | event: React.MouseEvent
155 | ) => {
156 | //prevent system context menu from showing when right clicking over the custom context menu
157 | event.stopPropagation()
158 | event.preventDefault()
159 | }
160 |
161 | if (!object) {
162 | //TODO: Default menu
163 | const options = renderOptions(menuOptions, 'dark')
164 | menu = (
165 |
168 | )
169 | } else if (object) {
170 | const { createdBy, modifiedBy, createdTime } = object
171 |
172 | const createdByCollaborator =
173 | createdBy && base.getCollaboratorByIdIfExists(createdBy)
174 | const modifiedByCollaborator =
175 | modifiedBy && base.getCollaboratorByIdIfExists(modifiedBy)
176 |
177 | const objectOptions = renderOptions(
178 | [
179 | {
180 | label: 'Rename',
181 | icon: 'edit',
182 | onClick: () => {
183 | setContextMenu(null)
184 | if (!(object instanceof fabric.ActiveSelection)) {
185 | setRenameObject(object)
186 | }
187 | },
188 | },
189 | {
190 | label: 'Bring To Front',
191 | icon: 'chevronUp',
192 | onClick: () => {
193 | object.bringToFront()
194 | setContextMenu(null)
195 | },
196 | },
197 | {
198 | label: 'Send To Back',
199 | icon: 'chevronDown',
200 | onClick: () => {
201 | object.sendToBack()
202 | setContextMenu(null)
203 | },
204 | },
205 | {
206 | label: 'Delete',
207 | icon: 'trash',
208 | onClick: () => {
209 | if (!canvas) return
210 | canvas.discardActiveObject()
211 | if (object instanceof fabric.ActiveSelection) {
212 | deleteActiveObjects(canvas)
213 | } else {
214 | canvas.remove(object)
215 | }
216 |
217 | setContextMenu(null)
218 | },
219 | },
220 | ],
221 | 'dark'
222 | )
223 |
224 | menu = (
225 |
267 | )
268 | }
269 |
270 | return (
271 | setContextMenu(null)}
275 | fitInWindowMode={'flip' as FitInWindowMode}
276 | backgroundStyle={{ pointerEvents: 'none' }}
277 | renderContent={() => (
278 |
279 | {menu}
280 |
281 | )}
282 | >
283 |
290 |
291 | )
292 | }
293 |
--------------------------------------------------------------------------------
/src/components/EmojiPicker.tsx:
--------------------------------------------------------------------------------
1 | import { BaseEmoji, NimblePicker } from 'emoji-mart'
2 | //@ts-expect-error
3 | import data from 'emoji-mart/data/apple.json'
4 | import React, { useRef } from 'react'
5 | import { FiSmile } from 'react-icons/fi'
6 |
7 | import { useHotkeys } from '../hooks'
8 | import { PopoverButton, PopoverButtonRef, shortcutsList } from './index'
9 |
10 | export const EmojiPicker: React.FC<{
11 | onSelect: (emoji: string) => void
12 | }> = ({ onSelect }) => {
13 | const popoverButtonRef = useRef(null)
14 |
15 | useHotkeys(
16 | shortcutsList.emojiPicker.shortcuts,
17 | () => {
18 | popoverButtonRef.current?.togglePopover()
19 | },
20 | [popoverButtonRef.current?.togglePopover]
21 | )
22 |
23 | return (
24 | }
29 | styleType="white"
30 | eventType="click"
31 | >
32 | {
45 | onSelect(emoji.native)
46 | popoverButtonRef.current?.togglePopover(false)
47 | }}
48 | />
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/FabricCanvas.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import React, { ForwardRefRenderFunction } from 'react'
3 | import isDeepEqual from 'react-fast-compare'
4 |
5 | type CanvasProps = { canvasOptions?: fabric.ICanvasOptions }
6 |
7 | const _Canvas: ForwardRefRenderFunction = (
8 | props,
9 | ref
10 | ) => {
11 | const { canvasOptions } = props
12 |
13 | const updateCanvas = (CanvasElement: HTMLCanvasElement) => {
14 | const canvas = new fabric.Canvas(CanvasElement, {
15 | stopContextMenu: true,
16 | fireRightClick: true,
17 | includeDefaultValues: false,
18 | hoverCursor: 'pointer',
19 | selectionBorderColor: '#009efe',
20 | selectionColor: 'rgb(226, 243, 255, 0.3)',
21 | ...canvasOptions,
22 | })
23 |
24 | if (typeof ref === 'function') {
25 | ref(canvas)
26 | } else if (ref) {
27 | ref.current = canvas
28 | }
29 | }
30 |
31 | return (
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | const _StaticCanvas: ForwardRefRenderFunction<
39 | fabric.StaticCanvas,
40 | CanvasProps
41 | > = (props, ref) => {
42 | const { canvasOptions } = props
43 |
44 | const updateCanvas = (CanvasElement: HTMLCanvasElement) => {
45 | const canvas = new fabric.StaticCanvas(CanvasElement, {
46 | ...canvasOptions,
47 | })
48 |
49 | if (typeof ref === 'function') {
50 | ref(canvas)
51 | } else if (ref) {
52 | ref.current = canvas
53 | }
54 | }
55 |
56 | return (
57 |
58 |
59 |
60 | )
61 | }
62 |
63 | export const FabricCanvas = React.memo(React.forwardRef(_Canvas), isDeepEqual)
64 | export const FabricStaticCanvas = React.memo(
65 | React.forwardRef(_StaticCanvas),
66 | isDeepEqual
67 | )
68 |
69 | const rotateIcon =
70 | 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAzMiAzNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTYuMjk3IDQuNHYtLjc0MWMwLS43NjguNDY0LTEuNDYyIDEuMjAzLTEuNzc2YTEuOTIyIDEuOTIyIDAgMDEyLjA2LjQzbDQuNTM3IDQuNTQ2YTEuOTEgMS45MSAwIDAxLjAwMiAyLjY5OGwtNC41NDkgNC41N2ExLjkyNiAxLjkyNiAwIDAxLTIuMDkuMzk2Yy0uNy0uMy0xLjE2My0uOTktMS4xNjMtMS43NTd2LTEuMTdhNy4yMzMgNy4yMzMgMCAwMC0uMTY0LS4wMDFjLTMuNzgzIDAtNi44NjMgMy4wOC02Ljg2MyA2Ljg2MSAwIDMuNzgxIDMuMDgyIDYuODYzIDYuODYzIDYuODYzIDMuNzggMCA2Ljg2MS0zLjA4MiA2Ljg2MS02Ljg2MyAwLTEuMDU0Ljg1Ni0xLjkxIDEuOTEyLTEuOTFoMy4zNjljMS4wNTQgMCAxLjkxMi44NTYgMS45MTIgMS45MSAwIDcuNzUtNi4zMDcgMTQuMDU5LTE0LjA1NCAxNC4wNTktNy43NSAwLTE0LjA1NS02LjMwOC0xNC4wNTUtMTQuMDU5IDAtNy43NTEgNi4zMDQtMTQuMDU3IDE0LjA1NS0xNC4wNTdsLjE2NC4wMDF6IiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHN0cm9rZT0iI0ZGRiIgc3Ryb2tlLXdpZHRoPSIyLjUiLz48L3N2Zz4='
71 |
72 | fabric.Object.prototype.cornerSize = 6
73 | fabric.Object.prototype.borderScaleFactor = 1.5
74 | fabric.Object.prototype.cornerColor = 'rgb(255, 255, 255)'
75 | fabric.Object.prototype.cornerStrokeColor = 'rgb(53, 167, 242,0.9)'
76 | fabric.Object.prototype.borderColor = 'rgb(53, 167, 242,0.9)'
77 | fabric.Object.prototype.transparentCorners = false
78 | fabric.Object.prototype.strokeUniform = true
79 | //@ts-expect-error
80 | const objectControls = fabric.Object.prototype.controls
81 | objectControls.mtr.offsetY = -15
82 | objectControls.mtr.cursorStyleHandler = () =>
83 | `url("${rotateIcon}") 10 10, crosshair`
84 | objectControls.mr.visible = false
85 | objectControls.ml.visible = false
86 | objectControls.mb.visible = false
87 | objectControls.mt.visible = false
88 |
--------------------------------------------------------------------------------
/src/components/IconButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { Box, Button, Tooltip } from '@airtable/blocks/ui'
4 | import styled from '@emotion/styled'
5 |
6 | import { useHotkeys, useResize } from '../hooks'
7 | import { Shortcut, shortcutsIds, shortcutsList } from './index'
8 |
9 | const IconButtonStyled = styled(Button)`
10 | height: 24px;
11 | padding: 0.25rem 0.5rem;
12 | margin: 0.25rem 0;
13 |
14 | &:focus {
15 | box-shadow: unset !important;
16 | }
17 | `
18 |
19 | export type IconButtonProps = {
20 | label: string
21 | labelMinWidth?: number
22 | labelStyle?: React.CSSProperties
23 | hideLabel?: boolean
24 | isSelected?: boolean
25 | shortcutId?: shortcutsIds
26 | } & React.ComponentProps
27 |
28 | export const IconButton: React.FC = (
29 | props
30 | ) => {
31 | const {
32 | hideLabel,
33 | labelMinWidth = 1000,
34 | labelStyle,
35 | label,
36 | isSelected,
37 | shortcutId,
38 | onClick,
39 | disabled,
40 | style,
41 | ...buttonProps
42 | } = props
43 | const [shouldShowLabel, setShouldShowLabel] = useState()
44 |
45 | const variant = isSelected ? 'default' : 'secondary'
46 |
47 | useResize(() => {
48 | if (!hideLabel && window.innerWidth > labelMinWidth) {
49 | setShouldShowLabel(true)
50 | } else {
51 | setShouldShowLabel(false)
52 | }
53 | })
54 |
55 | const shortcuts = shortcutId && shortcutsList[shortcutId].shortcuts
56 | useHotkeys(
57 | shortcuts,
58 | (e) => {
59 | e.preventDefault()
60 | if (onClick) {
61 | onClick()
62 | }
63 | },
64 | [onClick]
65 | )
66 |
67 | const tooltipContent = (
68 |
69 | {label}
70 | {shortcuts?.[0] && (
71 |
76 | )}
77 |
78 | )
79 |
80 | const disabledStyle: React.CSSProperties = {
81 | opacity: 0.5,
82 | cursor: 'not-allowed',
83 | }
84 |
85 | return (
86 | tooltipContent}
91 | >
92 | !disabled && onClick?.(e)}
96 | style={disabled ? { ...disabledStyle, ...style } : style}
97 | {...buttonProps}
98 | >
99 | {shouldShowLabel && {label}}
100 |
101 |
102 | )
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/LayersPanel.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import GraphemeSplitter from 'grapheme-splitter'
3 | import debounce from 'lodash/debounce'
4 | import React, { useContext, useEffect, useRef, useState } from 'react'
5 | import isDeepEqual from 'react-fast-compare'
6 | import { FiType } from 'react-icons/fi'
7 |
8 | import { Box, Icon } from '@airtable/blocks/ui'
9 | import styled from '@emotion/styled'
10 | import is from '@sindresorhus/is'
11 |
12 | import { ContextMenuEvent } from '../components'
13 | import { EditorContext } from '../Editor'
14 | import { shapesList } from '../tools'
15 |
16 | const splitter = new GraphemeSplitter()
17 |
18 | type objectListType = {
19 | [objectType: string]: { icon: React.ReactNode; label: string } | undefined
20 | }
21 |
22 | const objectList: objectListType = {
23 | rect: shapesList.Rectangle,
24 | ellipse: shapesList.Circle,
25 | path: shapesList.Pencil,
26 | textbox: { label: 'Text', icon: },
27 | group: shapesList.Arrow,
28 | }
29 |
30 | const LayerContainer = styled.div<{ isSelected?: boolean }>`
31 | display: flex;
32 | align-items: center;
33 | margin: 0.1rem 0;
34 | padding: 0.4rem 0.6rem;
35 | background: ${({ isSelected }) =>
36 | isSelected ? 'rgba(0, 0, 0, 0.05)' : 'none'};
37 |
38 | &:hover {
39 | background: rgba(0, 0, 0, 0.05);
40 | }
41 | `
42 |
43 | const Input = styled.input`
44 | background: none;
45 | border: none;
46 | flex: 1;
47 | margin: 0 0.5rem;
48 | color: hsl(0, 0%, 20%);
49 | width: 100%;
50 |
51 | &:focus,
52 | :active {
53 | outline: none;
54 | border: 2px solid rgba(0, 0, 0, 0.25);
55 | }
56 | `
57 |
58 | export const getLayerNameAndIcon = (object: fabric.Object) => {
59 | const type = object.type
60 | const conf = type ? objectList[type] : null
61 | if (object instanceof fabric.Textbox) {
62 | const isSingleCharacter =
63 | object.text && splitter.countGraphemes(object.text) === 1
64 |
65 | if (isSingleCharacter) {
66 | return {
67 | name: object.name || 'Emoji',
68 | icon: object.text,
69 | }
70 | }
71 | }
72 | return {
73 | name: object.name || (object as fabric.Textbox).text || conf?.label || type,
74 | icon: conf?.icon || ,
75 | }
76 | }
77 |
78 | type layerProps = {
79 | object: fabric.Object
80 | isSelected?: boolean
81 | setContextMenu: (menuEvent: ContextMenuEvent | null) => unknown
82 | canvas: fabric.Canvas
83 | }
84 |
85 | const _Layer: React.FC = (props) => {
86 | const { object, canvas, isSelected, setContextMenu } = props
87 | const inputRef = useRef(null)
88 | const { name, icon } = getLayerNameAndIcon(object)
89 |
90 | const [isRenaming, setIsRenaming] = useState(false)
91 | const [value, setValue] = useState(name)
92 |
93 | const save = () => {
94 | if (value !== name) {
95 | object.set('name', value)
96 | object.canvas?.fire('state:modified', { target: object })
97 | }
98 | setIsRenaming(false)
99 | }
100 |
101 | const selectLayer = () => {
102 | let selectedObjects = canvas?.getActiveObjects()
103 | canvas.discardActiveObject()
104 |
105 | if (selectedObjects?.includes(object)) {
106 | selectedObjects = selectedObjects.filter(
107 | (selectedObject) => selectedObject !== object
108 | )
109 | } else {
110 | selectedObjects = selectedObjects
111 | ? [...selectedObjects, object]
112 | : [object]
113 | }
114 |
115 | if (is.nonEmptyArray(selectedObjects)) {
116 | const selection = new fabric.ActiveSelection(selectedObjects, {
117 | canvas: canvas,
118 | })
119 | canvas.setActiveObject(selection)
120 | }
121 |
122 | canvas.requestRenderAll()
123 | }
124 |
125 | return (
126 | {
129 | e.preventDefault()
130 | setContextMenu({ position: { x: e.clientX, y: e.clientY }, object })
131 | }}
132 | onClick={selectLayer}
133 | onDoubleClick={() => {
134 | setIsRenaming(true)
135 | }}
136 | >
137 | {icon}
138 | {isRenaming ? (
139 | ) => {
144 | setValue(e.target.value)
145 | }}
146 | onBlur={save}
147 | onKeyDown={(event) => {
148 | if (event.key === 'Enter') {
149 | save()
150 | }
151 | }}
152 | />
153 | ) : (
154 |
160 | {value}
161 |
162 | )}
163 |
164 | )
165 | }
166 |
167 | const Layer = React.memo(_Layer, isDeepEqual)
168 |
169 | export const LayersPanel: React.FC = () => {
170 | const { setContextMenu, canvas } = useContext(EditorContext)
171 | const [objects, setObjects] = useState(canvas?.getObjects())
172 |
173 | useEffect(() => {
174 | if (!canvas) return
175 |
176 | const updateObjects = debounce(() => {
177 | setObjects(canvas?.getObjects())
178 | }, 50)
179 |
180 | updateObjects()
181 |
182 | canvas.on('after:render', updateObjects)
183 | return () => {
184 | canvas.off('after:render', updateObjects)
185 | }
186 | }, [canvas])
187 |
188 | return objects?.length && canvas ? (
189 | <>
190 | {objects.map((object) => (
191 |
198 | ))}
199 | >
200 | ) : (
201 | No Layers
202 | )
203 | }
204 |
--------------------------------------------------------------------------------
/src/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { Box, TextButton } from '@airtable/blocks/ui'
2 | import { css } from '@emotion/core'
3 | import styled from '@emotion/styled'
4 |
5 | const TOOLBAR_HEIGHT = 44
6 |
7 | export const hideScrollbar = css`
8 | &::-webkit-scrollbar {
9 | display: none;
10 | }
11 | `
12 |
13 | export const lightScrollbar = css`
14 | &::-webkit-scrollbar {
15 | width: 12px;
16 | height: 12px;
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 | right: 0;
21 | bottom: 0;
22 | }
23 |
24 | &::-webkit-scrollbar-button {
25 | display: none;
26 | height: 0;
27 | width: 0;
28 | }
29 |
30 | &::-webkit-scrollbar-thumb {
31 | background-color: hsla(0, 0%, 0%, 0.35);
32 | background-clip: padding-box;
33 | border: 3px solid rgba(0, 0, 0, 0);
34 | border-radius: 6px;
35 | min-height: 36px;
36 | }
37 |
38 | &::-webkit-scrollbar-thumb:hover {
39 | background-color: hsla(0, 0%, 0%, 0.4);
40 | }
41 | `
42 |
43 | export const Divider = styled(Box)`
44 | border-bottom: 2px solid rgba(0, 0, 0, 0.1);
45 | `
46 |
47 | export const EditorContainer = styled.div`
48 | width: 100vw;
49 | height: 100vh;
50 | display: flex;
51 | justify-content: center;
52 | align-items: center;
53 | flex-direction: column;
54 | `
55 |
56 | export const CanvasContainer = styled.div`
57 | display: flex;
58 | flex: 1;
59 | justify-content: center;
60 | align-items: center;
61 | overflow: hidden;
62 | `
63 |
64 | export const ToolbarContainer = styled.div`
65 | ${hideScrollbar}
66 | width: 100%;
67 | height: ${TOOLBAR_HEIGHT}px;
68 | display: flex;
69 | align-items: center;
70 | overflow: scroll;
71 | border-bottom: solid 2px rgba(0, 0, 0, 0.1);
72 | `
73 | export type menuVariants = 'white' | 'dark'
74 |
75 | export const Menu = styled.div<{ type?: menuVariants }>`
76 | position: relative;
77 | margin: 4px;
78 | overflow: hidden;
79 | border-radius: 3px;
80 | box-sizing: border-box;
81 | pointer-events: all;
82 | ${({ type }) =>
83 | type === 'dark'
84 | ? css`
85 | background-color: hsl(0, 0%, 20%);
86 | color: #fff;
87 | fill: #fff;
88 | `
89 | : css`
90 | color: hsl(0, 0%, 30%);
91 | background-color: #fff;
92 | box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
93 | `}
94 | `
95 |
96 | type MenuItemProps = { type: menuVariants; isSelected?: boolean }
97 |
98 | export const MenuItem = styled(TextButton)`
99 | padding-top: 0.5rem;
100 | padding-bottom: 0.5rem;
101 | padding-left: 1rem;
102 | padding-right: 1rem;
103 | display: flex;
104 | align-items: center;
105 | transition: 0.085s all ease-in;
106 | justify-content: start;
107 | opacity: 1;
108 | margin: 0;
109 | border-radius: 0;
110 | opacity: 1 !important;
111 |
112 | & span {
113 | flex: 1;
114 | }
115 |
116 | ${({ type, isSelected }) =>
117 | type === 'dark'
118 | ? css`
119 | padding: 0.5rem;
120 | color: #fff;
121 | fill: #fff;
122 |
123 | &:hover,
124 | &:focus {
125 | background-color: hsla(0, 0%, 100%, 0.1);
126 | }
127 | `
128 | : css`
129 | color: ${isSelected ? '#2d7ff9' : 'hsl(0, 0%, 30%)'};
130 | background: white;
131 |
132 | &:hover {
133 | background: rgba(0, 0, 0, 0.05);
134 | }
135 | `}
136 | `
137 |
--------------------------------------------------------------------------------
/src/components/LeftPanel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 |
3 | import { Box, Text } from '@airtable/blocks/ui'
4 |
5 | import { Annotation } from '../Annotation'
6 | import {
7 | AttachmentPanel,
8 | Divider,
9 | IconButton,
10 | LayersPanel,
11 | } from '../components'
12 | import { Attachment } from '../hooks'
13 | import { Panel, SidePanel } from './SidePanel'
14 |
15 | type LeftPanelProps = {
16 | attachments: Attachment[] | null
17 | onClickAttachment: (record: Attachment) => void
18 | activeAnnotation: Annotation | null
19 | }
20 |
21 | export const LeftPanel: React.FC = (props) => {
22 | const { attachments, onClickAttachment, activeAnnotation } = props
23 | const [showLayersPanel, setShowLayersPanel] = useState(!!activeAnnotation)
24 |
25 | useEffect(() => {
26 | if (!activeAnnotation) setShowLayersPanel(false)
27 | }, [activeAnnotation])
28 |
29 | return (
30 |
31 | {showLayersPanel ? (
32 | <>
33 |
34 | setShowLayersPanel(false)}
40 | />
41 |
49 | {activeAnnotation?.name}
50 |
51 |
52 |
53 |
54 |
55 |
56 | >
57 | ) : (
58 |
59 | {attachments?.length ? (
60 | {
63 | onClickAttachment(record)
64 | setShowLayersPanel(true)
65 | }}
66 | />
67 | ) : (
68 |
69 | No Annotations
70 |
71 | )}
72 |
73 | )}
74 |
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/PopoverButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 |
3 | import { Box, Popover, TextButton } from '@airtable/blocks/ui'
4 | import is from '@sindresorhus/is'
5 |
6 | import { IconButton, IconButtonProps, Shortcut } from './index'
7 | import { shortcutsIds, shortcutsList } from './keyboardShortcutsList'
8 | import { Menu, MenuItem, menuVariants } from './Layout'
9 |
10 | type PopoverButtonProps = IconButtonProps & {
11 | styleType: menuVariants
12 | eventType: 'click' | 'hover'
13 | label?: React.ReactNode
14 | options?: MenuOption[]
15 | fitInWindowMode?: Popover['props']['fitInWindowMode']
16 | closeOnClick?: boolean
17 | }
18 |
19 | export type PopoverButtonRef = {
20 | togglePopover: (state?: boolean) => void
21 | }
22 |
23 | const _PopoverButton: React.ForwardRefRenderFunction<
24 | PopoverButtonRef,
25 | PopoverButtonProps
26 | > = (props, ref) => {
27 | const {
28 | styleType,
29 | eventType,
30 | children,
31 | options,
32 | fitInWindowMode,
33 | closeOnClick,
34 | ...buttonProps
35 | } = props
36 | const [isOpen, setIsOpen] = useState(false)
37 |
38 | const clickProps = {
39 | onClick: () => setIsOpen(!isOpen),
40 | }
41 |
42 | const hoverProps = {
43 | onMouseEnter: () => setIsOpen(true),
44 | onMouseLeave: () => setIsOpen(false),
45 | }
46 |
47 | const closePopoverOnClick = () => {
48 | if (closeOnClick) {
49 | setIsOpen(false)
50 | }
51 | }
52 |
53 | useEffect(() => {
54 | const togglePopover = (state?: boolean) => {
55 | setIsOpen(is.boolean(state) ? state : !isOpen)
56 | }
57 | if (ref && is.function_(ref)) {
58 | ref({ togglePopover })
59 | } else if (ref) {
60 | ref.current = { togglePopover }
61 | }
62 | }, [isOpen, setIsOpen, ref])
63 |
64 | return (
65 | setIsOpen(false)}
68 | fitInWindowMode={fitInWindowMode || Popover.fitInWindowModes.FLIP}
69 | renderContent={() => (
70 |
75 | )}
76 | >
77 |
82 |
83 | )
84 | }
85 |
86 | export const PopoverButton = React.forwardRef(_PopoverButton)
87 |
88 | // Menu Options
89 |
90 | export type MenuOption =
91 | | ({
92 | label: React.ReactNode
93 | isSelected?: boolean
94 | shortcutId?: shortcutsIds
95 | } & React.ComponentProps)
96 | | React.ReactElement
97 |
98 | export const renderOptions = (
99 | options: MenuOption[],
100 | variant: menuVariants,
101 | closePopover?: () => void
102 | ) => {
103 | const optionElements = options?.map((option, index) => {
104 | if (React.isValidElement(option)) {
105 | return option
106 | } else if (is.plainObject(option)) {
107 | const { label, isSelected, onClick, shortcutId, ...buttonProps } = option
108 |
109 | const shortcuts = shortcutId && shortcutsList[shortcutId].shortcuts
110 | return (
111 |
132 | )
133 | }
134 | })
135 |
136 | return (
137 |
138 | {optionElements}
139 |
140 | )
141 | }
142 |
--------------------------------------------------------------------------------
/src/components/PropertiesInputs.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 |
3 | import { SelectOption } from '@airtable/blocks/dist/types/src/ui/select_and_select_buttons_helpers'
4 | import {
5 | ColorPalette,
6 | colors,
7 | colorUtils,
8 | FormField,
9 | Input,
10 | Select,
11 | SelectButtons,
12 | Switch,
13 | } from '@airtable/blocks/ui'
14 | import is from '@sindresorhus/is'
15 |
16 | import { DEFAULT_COLOR } from '../hooks'
17 | import { PropertiesContext, PropertiesOptions } from './PropertiesPanel'
18 |
19 | type PropertyInputProps = {
20 | label: React.ReactNode
21 | property: keyof PropertiesOptions
22 | defaultValue?: string
23 | description?: React.ReactNode | string | null
24 | }
25 |
26 | export const PropertyInput: React.FC = (props) => {
27 | const { property, defaultValue, label, description } = props
28 | const { getValue, setValue } = useContext(PropertiesContext)
29 | const value = getValue(property)
30 | return (
31 |
32 | {
35 | const newValue = event.target.value
36 | setValue({ [property]: newValue })
37 | }}
38 | />
39 |
40 | )
41 | }
42 |
43 | type PropertySelectProps = PropertyInputProps & {
44 | options: SelectOption[] | (number | string)[]
45 | buttons?: boolean
46 | }
47 |
48 | export const PropertySelect: React.FC = (props) => {
49 | const { property, label, description, buttons } = props
50 | const { getValue, setValue } = useContext(PropertiesContext)
51 | const value = getValue(property)
52 | const options = is.array(props.options, is.object)
53 | ? props.options
54 | : props.options.map((option) => ({
55 | label: option,
56 | value: option,
57 | }))
58 |
59 | const SelectComponent = buttons ? SelectButtons : Select
60 |
61 | return (
62 |
63 | {
67 | setValue({ [property]: newValue })
68 | }}
69 | />
70 |
71 | )
72 | }
73 |
74 | type PropertySwitchProps<
75 | T extends keyof PropertiesOptions
76 | > = PropertyInputProps & {
77 | property: T
78 | true?: PropertiesOptions[T]
79 | false?: PropertiesOptions[T]
80 | }
81 |
82 | export function PropertySwitch(
83 | props: PropertySwitchProps
84 | ) {
85 | const { property, label, description } = props
86 | const { getValue, setValue } = useContext(PropertiesContext)
87 | let value: any = getValue(property)
88 | value = value === props.false ? false : value
89 | value = value === props.true ? true : value
90 |
91 | return (
92 | >} description={description}>
93 | {
97 | const newValue: any = value
98 | ? props.true ?? value
99 | : props.false ?? value
100 | setValue({ [property]: newValue })
101 | }}
102 | />
103 |
104 | )
105 | }
106 |
107 | // prettier-ignore
108 | export const allowedColors = [
109 | colors.BLUE_LIGHT_2, colors.GRAY_LIGHT_2, colors.GREEN_LIGHT_2, colors.ORANGE_LIGHT_2, colors.RED_LIGHT_2, colors.YELLOW_LIGHT_2,
110 | colors.BLUE_BRIGHT, colors.GRAY_BRIGHT, colors.GREEN_BRIGHT, colors.ORANGE_BRIGHT, colors.RED_BRIGHT, colors.YELLOW_BRIGHT,
111 | colors.BLUE_DARK_1, colors.GRAY_DARK_1, colors.GREEN_DARK_1, colors.ORANGE_DARK_1, colors.RED_DARK_1, colors.YELLOW_DARK_1,
112 | ]
113 |
114 | type PropertyColorProps = PropertyInputProps
115 |
116 | export const PropertyColor: React.FC = (props) => {
117 | const { property, label, description } = props
118 | const { getValue, setValue } = useContext(PropertiesContext)
119 | const value = getValue(property)
120 |
121 | const fillColor =
122 | value &&
123 | allowedColors.find((color) => value === colorUtils.getHexForColor(color))
124 |
125 | const isTransparent = !fillColor || value === 'transparent'
126 |
127 | return (
128 |
129 | {
137 | if (setAsTransparent) {
138 | setValue({ [property]: 'transparent' })
139 | } else {
140 | setValue({ [property]: colorUtils.getHexForColor(DEFAULT_COLOR) })
141 | }
142 | }}
143 | />
144 | {!isTransparent && (
145 | {
149 | const newColorHex = colorUtils.getHexForColor(newColor)
150 | setValue({ [property]: newColorHex })
151 | }}
152 | allowedColors={allowedColors}
153 | width="160px"
154 | />
155 | )}
156 |
157 | )
158 | }
159 |
--------------------------------------------------------------------------------
/src/components/PropertiesPanel.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions, ITextOptions } from 'fabric/fabric-impl'
3 | import React from 'react'
4 |
5 | import { SelectOption } from '@airtable/blocks/dist/types/src/ui/select_and_select_buttons_helpers'
6 | import { Box, Icon, Text } from '@airtable/blocks/ui'
7 |
8 | import { SidePanel } from './index'
9 | import { getLayerNameAndIcon } from './LayersPanel'
10 | import {
11 | PropertyColor,
12 | PropertySelect,
13 | PropertySwitch,
14 | } from './PropertiesInputs'
15 |
16 | export type PropertiesOptions = IObjectOptions & ITextOptions
17 |
18 | interface PropertiesContextValue {
19 | getValue: (
20 | key: T
21 | ) => PropertiesOptions[T] | null
22 | setValue: (newValue: PropertiesOptions) => void
23 | }
24 |
25 | export const PropertiesContext = React.createContext(
26 | {} as any
27 | )
28 |
29 | type PropertiesPanelProps = {
30 | activeLayer: fabric.Object | null
31 | styleValue: PropertiesOptions | null
32 | onChange: (value: PropertiesOptions) => void
33 | }
34 |
35 | export const PropertiesPanel: React.FC = (props) => {
36 | const { activeLayer, styleValue, onChange } = props
37 | const layerInfo = activeLayer && getLayerNameAndIcon(activeLayer)
38 | const isTextLayer = activeLayer?.isType('textbox')
39 |
40 | const contextValue: PropertiesContextValue = {
41 | getValue: (key) => styleValue?.[key],
42 | setValue: (newValue) => onChange({ ...styleValue, ...newValue }),
43 | }
44 |
45 | return (
46 |
47 |
48 | {layerInfo && (
49 |
50 | {layerInfo?.icon} {layerInfo?.name}
51 |
52 | )}
53 | {isTextLayer && (
54 | <>
55 |
60 |
66 |
67 |
71 | >
72 | )}
73 |
79 |
85 |
86 |
87 |
88 |
89 | )
90 | }
91 |
92 | const TextSizes = [12, 18, 24, 36, 48, 64, 72, 96, 144, 288]
93 |
94 | const scaleY = (number: number) => ({
95 | transform: `scaleY(${number}) scaleX(${
96 | number * 0.75
97 | }) translateY(${number}px)`,
98 | })
99 |
100 | const strokeOptions: SelectOption[] = [
101 | {
102 | label: ,
103 | value: 4,
104 | },
105 | {
106 | label: ,
107 | value: 8,
108 | },
109 | {
110 | label: ,
111 | value: 12,
112 | },
113 | ]
114 |
--------------------------------------------------------------------------------
/src/components/Setup.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 |
3 | import { globalConfig, session } from '@airtable/blocks'
4 | import { FieldType } from '@airtable/blocks/models'
5 | import {
6 | Button,
7 | Dialog,
8 | Heading,
9 | Text,
10 | TextButton,
11 | useBase,
12 | ViewportConstraint,
13 | } from '@airtable/blocks/ui'
14 |
15 | import { snackbar } from '../components'
16 | import { BlockContext } from '../Main'
17 | import { globalConfigKeys } from '../Settings'
18 |
19 | const STORAGE_FIELD_NAME = 'Storage'
20 |
21 | export const Setup: React.FC = () => {
22 | const base = useBase()
23 | const { showSettings } = useContext(BlockContext)
24 |
25 | const createTable = () =>
26 | base
27 | .createTableAsync('Annotations', [
28 | { name: 'Image ID', type: FieldType.SINGLE_LINE_TEXT },
29 | { name: STORAGE_FIELD_NAME, type: FieldType.MULTILINE_TEXT },
30 | ])
31 | .then((table) => {
32 | globalConfig.setPathsAsync([
33 | {
34 | path: [globalConfigKeys.annotationsTableId],
35 | value: table.id,
36 | },
37 | {
38 | path: [globalConfigKeys.storageFieldId],
39 | value: table.getFieldByNameIfExists(STORAGE_FIELD_NAME)?.id,
40 | },
41 | ])
42 | })
43 | .catch((error: Error) => {
44 | snackbar(error.message, 5)
45 | })
46 | const permissionsForCreateTable = base.checkPermissionsForCreateTable()
47 |
48 | return (
49 |
50 |
67 |
68 | )
69 | }
70 |
--------------------------------------------------------------------------------
/src/components/SidePanel.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { IconName } from '@airtable/blocks/dist/types/src/ui/icon_config'
4 | import { Box, Icon, Text } from '@airtable/blocks/ui'
5 | import { css } from '@emotion/core'
6 | import styled from '@emotion/styled'
7 | import is from '@sindresorhus/is'
8 |
9 | import { hideScrollbar, lightScrollbar } from './index'
10 |
11 | const TOOLBAR_HEIGHT = 44
12 |
13 | type PanelProps = {
14 | title: React.ReactNode
15 | icon?: IconName | React.ReactElement
16 | extra?: React.ReactNode
17 | showScrollbar?: boolean
18 | } & React.ComponentProps
19 |
20 | export const Panel: React.FC = (props) => {
21 | const {
22 | title,
23 | icon,
24 | showScrollbar,
25 | extra,
26 | children,
27 | ...containerProps
28 | } = props
29 |
30 | return (
31 |
32 |
33 | {is.string(icon) ? (
34 |
35 | ) : (
36 | icon
37 | )}
38 |
39 | {title}
40 |
41 | {extra}
42 |
43 |
44 |
45 | {children}
46 |
47 |
48 |
49 | )
50 | }
51 |
52 | type SidePanelProps = {
53 | enableScrolling?: boolean
54 | side: 'left' | 'right'
55 | } & React.ComponentProps
56 |
57 | export const SidePanel: React.FC = (props) => {
58 | const { enableScrolling, side, children, ...containerProps } = props
59 |
60 | return (
61 |
62 | {enableScrolling ? (
63 |
64 | {children}
65 |
66 | ) : (
67 | children
68 | )}
69 |
70 | )
71 | }
72 |
73 | type SidePanelContainerProps = {
74 | side: 'left' | 'right'
75 | }
76 |
77 | export const SidePanelContainer = styled.div`
78 | display: flex;
79 | flex-direction: column;
80 | flex: none;
81 | box-sizing: border-box;
82 | overflow: auto;
83 | position: relative;
84 | z-index: 5;
85 | width: 240px;
86 | background-color: #fff;
87 | height: calc(100vh - ${TOOLBAR_HEIGHT}px);
88 |
89 | ${({ side }) => {
90 | return side === 'right'
91 | ? css`
92 | right: 0;
93 | border-left: solid 2px rgba(77, 77, 77, 0.3);
94 | `
95 | : css`
96 | left: 0;
97 | border-right: solid 2px rgba(77, 77, 77, 0.3);
98 | `
99 | }}
100 |
101 | ${lightScrollbar}
102 | @media (max-width: 768px) {
103 | width: 200px;
104 | /* position: absolute; */
105 | }
106 | `
107 |
108 | export const PanelContainer = styled.div`
109 | overflow: hidden;
110 | display: flex;
111 | flex-direction: column;
112 | height: 100%;
113 | `
114 |
115 | export const PanelTitle = styled.div`
116 | display: flex;
117 | color: hsl(0, 0%, 30%);
118 | align-items: center;
119 | padding: 0 0.5rem;
120 | margin: 0;
121 | height: 2rem;
122 | overflow: hidden;
123 | text-transform: uppercase;
124 | letter-spacing: 0.1em;
125 | `
126 |
127 | type ScrollContainerProps = {
128 | showScrollbar?: boolean
129 | }
130 |
131 | export const ScrollContainer = styled(Box)`
132 | box-sizing: border-box;
133 | overflow: auto;
134 | overflow-x: hidden;
135 | height: 100%;
136 | ${({ showScrollbar }) => (showScrollbar ? lightScrollbar : hideScrollbar)}
137 | `
138 |
139 | export const InnerScrollContainer = styled.div`
140 | display: flex;
141 | flex-direction: column;
142 | width: 100%;
143 | `
144 |
--------------------------------------------------------------------------------
/src/components/Snackbar.tsx:
--------------------------------------------------------------------------------
1 | import Notification from 'rc-notification'
2 | import { NotificationInstance } from 'rc-notification/lib/Notification'
3 |
4 | let snackbarInstance: NotificationInstance | null = null
5 |
6 | Notification.newInstance(
7 | {
8 | maxCount: 2,
9 | style: {
10 | bottom: 5,
11 | left: 5,
12 | zIndex: 100000000,
13 | },
14 | },
15 | (notification) => {
16 | snackbarInstance = notification
17 | }
18 | )
19 |
20 | export const snackbar = (message: React.ReactNode, duration?: number) => {
21 | snackbarInstance?.notice({
22 | content: message,
23 | duration: duration || 1.5,
24 | style: {
25 | margin: 5,
26 | padding: '12px 18px',
27 | background: 'hsl(0,0%,20%)',
28 | color: '#f6f6f6',
29 | borderRadius: 4,
30 | },
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import React, { useContext, useRef, useState } from 'react'
3 | import { FiMousePointer, FiMove, FiType } from 'react-icons/fi'
4 |
5 | import { PermissionCheckResult } from '@airtable/blocks/dist/types/src/types/mutations'
6 | import { Box, colorUtils } from '@airtable/blocks/ui'
7 |
8 | import { EditorContext } from '../Editor'
9 | import { DEFAULT_COLOR, useHotkeys } from '../hooks'
10 | import { Move, Select, shapesList } from '../tools'
11 | import { EmojiPicker } from './EmojiPicker'
12 | import {
13 | IconButton,
14 | MenuOption,
15 | PopoverButton,
16 | PopoverButtonRef,
17 | ToolbarContainer,
18 | toolsKeys,
19 | } from './index'
20 |
21 | type ToolbarProps = {
22 | ToolbarButtons?: React.ReactNode
23 | ToolbarButtonsRight?: React.ReactNode
24 | updatePermission?: PermissionCheckResult | null
25 | }
26 |
27 | export const Toolbar: React.FC = (props) => {
28 | const { updatePermission, ToolbarButtons, ToolbarButtonsRight } = props
29 |
30 | const { canvas, activeTool, handleToolChange } = useContext(EditorContext)
31 |
32 | const toolsListMenu = useRef(null)
33 | const [selectedShape, setSelectedShape] = useState<
34 | typeof shapesList[keyof typeof shapesList]
35 | >(shapesList.Rectangle)
36 |
37 | useHotkeys(
38 | Object.keys(toolsKeys),
39 | (_k, event) => {
40 | const key = event.key as keyof typeof toolsKeys
41 | const tool = toolsKeys[key]
42 |
43 | const updateSelectedShape = (shapeName: keyof typeof shapesList) => {
44 | const tool = shapesList[shapeName]
45 | if (tool && canvas) {
46 | const newTool = new tool.class(canvas)
47 | canvas && handleToolChange(newTool)
48 | setSelectedShape(tool)
49 | }
50 | }
51 |
52 | switch (tool) {
53 | case 'Arrow':
54 | case 'Circle':
55 | case 'Line':
56 | case 'Pencil':
57 | case 'Rectangle':
58 | return updateSelectedShape(tool)
59 | }
60 | },
61 | [handleToolChange, setSelectedShape]
62 | )
63 |
64 | const createText = (text: string = 'Text') => {
65 | if (!canvas) return
66 | const textObject = new fabric.Textbox(text, {
67 | fill: colorUtils.getHexForColor(DEFAULT_COLOR)!,
68 | fontSize: 64,
69 | fontFamily: "-apple-system,system-ui,BlinkMacSystemFont,'Segoe UI'",
70 | })
71 | canvas.add(textObject)
72 | canvas.viewportCenterObject(textObject)
73 | canvas.setActiveObject(textObject)
74 | handleToolChange(null)
75 | }
76 |
77 | const toolsOptions = Object.values(shapesList).map(
78 | (tool): MenuOption => {
79 | const newTool = canvas && new tool.class(canvas)
80 |
81 | const selectTool = () => {
82 | canvas && handleToolChange(newTool)
83 | setSelectedShape(tool)
84 | }
85 |
86 | return {
87 | label: tool.label,
88 | icon: tool.icon,
89 | shortcutId: tool.label,
90 | isSelected: activeTool instanceof tool.class,
91 | onClick: selectTool,
92 | }
93 | }
94 | )
95 |
96 | const isSelectingShape = activeTool instanceof selectedShape.class
97 |
98 | return (
99 |
100 | {ToolbarButtons}
101 | {updatePermission && !updatePermission.hasPermission && (
102 |
112 | )}
113 |
114 | }
116 | label="Select"
117 | shortcutId="select"
118 | onClick={() => canvas && handleToolChange(new Select(canvas))}
119 | isSelected={activeTool instanceof Select}
120 | />
121 | }
123 | label="Move"
124 | shortcutId="move"
125 | onClick={() => canvas && handleToolChange(new Move(canvas))}
126 | isSelected={activeTool instanceof Move}
127 | />
128 |
137 | {
144 | if (activeTool?.name === selectedShape?.class.name) {
145 | toolsListMenu.current?.togglePopover()
146 | } else {
147 | canvas &&
148 | selectedShape &&
149 | handleToolChange(new selectedShape.class(canvas))
150 | }
151 | }}
152 | />
153 |
164 |
165 | }
167 | label="Text"
168 | shortcutId="Text"
169 | onClick={() => createText()}
170 | />
171 |
172 |
173 | {ToolbarButtonsRight}
174 |
175 | )
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Snackbar'
2 | export * from './LayersPanel'
3 | export * from './AttachmentPanel'
4 | export * from './keyboardShortcutsList'
5 | export * from './FabricCanvas'
6 | export * from './ContextMenu'
7 | export * from './CanvasHover'
8 | export * from './Layout'
9 | export * from './PopoverButton'
10 | export * from './Confirm'
11 | export * from './Setup'
12 | export * from './IconButton'
13 | export * from './Toolbar'
14 | export * from './SidePanel'
15 | export * from './LeftPanel'
16 | export * from './PropertiesPanel'
17 |
--------------------------------------------------------------------------------
/src/components/keyboardShortcutsList.tsx:
--------------------------------------------------------------------------------
1 | import groupBy from 'lodash/groupBy'
2 | import React from 'react'
3 |
4 | import { BoxProps } from '@airtable/blocks/dist/types/src/ui/box'
5 | import { Box, Dialog, Heading, Text } from '@airtable/blocks/ui'
6 | import { css } from '@emotion/core'
7 | import styled from '@emotion/styled'
8 |
9 | import { Divider } from './Layout'
10 |
11 | export type shortcutsIds =
12 | | 'select'
13 | | 'move'
14 | | 'download'
15 | | 'newAnnotation'
16 | | 'deleteShape'
17 | | 'keyboardShortcuts'
18 | | 'sidebar'
19 | | 'previousRecord'
20 | | 'nextRecord'
21 | | 'expandRecord'
22 | | 'lookupRecord'
23 | | 'emojiPicker'
24 | | 'Circle'
25 | | 'Pencil'
26 | | 'Rectangle'
27 | | 'Line'
28 | | 'Arrow'
29 | | 'Text'
30 | | 'previousAnnotation'
31 | | 'nextAnnotation'
32 | | 'nextPreviousAnnotation'
33 |
34 | const groups = ['General', 'Shapes & Tools'] as const
35 |
36 | type shortcutsListType = {
37 | [name in shortcutsIds]: {
38 | label: string
39 | group: typeof groups[number]
40 | shortcuts: string[]
41 | }
42 | }
43 |
44 | export const shortcutsList: shortcutsListType = {
45 | select: { label: 'Select', shortcuts: ['v'], group: 'Shapes & Tools' },
46 | move: {
47 | label: 'Move',
48 | shortcuts: ['m'],
49 | group: 'Shapes & Tools',
50 | },
51 | download: {
52 | label: 'Download as an image',
53 | shortcuts: ['command+s', 'control+s'],
54 | group: 'General',
55 | },
56 | newAnnotation: {
57 | label: 'New annotation',
58 | shortcuts: ['shift+enter'],
59 | group: 'General',
60 | },
61 | deleteShape: {
62 | label: 'delete selected shape',
63 | shortcuts: ['command+backspace', 'shift+backspace', 'backspace'],
64 | group: 'General',
65 | },
66 | previousRecord: {
67 | label: 'Previous record',
68 | shortcuts: ['command+shift+,'],
69 | group: 'General',
70 | },
71 | nextRecord: {
72 | label: 'Next record',
73 | shortcuts: ['command+shift+.'],
74 | group: 'General',
75 | },
76 | previousAnnotation: {
77 | label: 'Previous annotation',
78 | shortcuts: ['command+left'],
79 | group: 'General',
80 | },
81 | nextAnnotation: {
82 | label: 'Next annotation',
83 | shortcuts: ['command+right'],
84 | group: 'General',
85 | },
86 | nextPreviousAnnotation: {
87 | label: 'To use the toolbar record arrows for annotations',
88 | shortcuts: ['shift+click'],
89 | group: 'General',
90 | },
91 | expandRecord: {
92 | label: 'Expand record',
93 | shortcuts: ['space'],
94 | group: 'General',
95 | },
96 | lookupRecord: {
97 | label: 'Search for record',
98 | shortcuts: ['command+f'],
99 | group: 'General',
100 | },
101 | keyboardShortcuts: {
102 | label: 'keyboard shortcuts',
103 | shortcuts: ['command+?', 'control+?', 'command+/', 'control+/'],
104 | group: 'General',
105 | },
106 | sidebar: {
107 | label: 'Sidebar',
108 | shortcuts: ['command+shift+k', 'control+shift+k'],
109 | group: 'General',
110 | },
111 | emojiPicker: {
112 | label: 'Emoji picker',
113 | shortcuts: ['e'],
114 | group: 'Shapes & Tools',
115 | },
116 | Circle: {
117 | label: 'Circle',
118 | shortcuts: ['o'],
119 | group: 'Shapes & Tools',
120 | },
121 | Arrow: {
122 | label: 'Arrow',
123 | shortcuts: ['shift+l'],
124 | group: 'Shapes & Tools',
125 | },
126 | Pencil: {
127 | label: 'Pencil',
128 | shortcuts: ['p'],
129 | group: 'Shapes & Tools',
130 | },
131 | Rectangle: {
132 | label: 'Rectangle',
133 | shortcuts: ['r'],
134 | group: 'Shapes & Tools',
135 | },
136 | Line: {
137 | label: 'Line',
138 | shortcuts: ['l'],
139 | group: 'Shapes & Tools',
140 | },
141 | Text: {
142 | label: 'Line',
143 | shortcuts: ['t'],
144 | group: 'Shapes & Tools',
145 | },
146 | }
147 |
148 | export const toolsKeys = {
149 | e: 'Emoji',
150 | r: 'Rectangle',
151 | p: 'Pencil',
152 | l: 'Line',
153 | o: 'Circle',
154 | 'shift+l': 'Arrow',
155 | } as const
156 |
157 | type ShortcutKeyVariants = 'dark' | 'white'
158 |
159 | export const ShortcutKey = styled.kbd<{ variant?: ShortcutKeyVariants }>`
160 | display: inline-block;
161 | border-radius: 3px;
162 | font-size: 10px;
163 | margin-left: 2px;
164 | min-width: 12px;
165 | padding: 1px 3px;
166 | box-sizing: border-box;
167 | text-transform: uppercase;
168 | text-align: center;
169 | user-select: none;
170 | white-space: nowrap;
171 | font: inherit;
172 | font-size: 10px;
173 | ${({ variant }) => {
174 | return variant === 'dark'
175 | ? css`
176 | background-color: hsla(0, 0%, 100%, 0.25);
177 | color: #fff;
178 | fill: #fff;
179 | font-weight: 500;
180 | line-height: 1.25;
181 | `
182 | : css`
183 | background-color: hsla(0, 0%, 100%, 0.25);
184 | border-color: rgba(0, 0, 0, 0.1);
185 | color: #333333;
186 | border-style: solid;
187 | border-width: 1px;
188 | border-bottom-width: 2px;
189 | `
190 | }}
191 | `
192 |
193 | const keyIconList = {
194 | command: '⌘',
195 | left: '←',
196 | right: '→',
197 | } as any
198 |
199 | export const Shortcut: React.FC<
200 | { keyCombinations: string; variant?: ShortcutKeyVariants } & BoxProps
201 | > = ({ keyCombinations, variant, ...BoxProps }) => {
202 | return (
203 |
204 | {keyCombinations.split('+').map((key) => (
205 |
206 | {keyIconList[key] || key}
207 |
208 | ))}
209 |
210 | )
211 | }
212 |
213 | type KeyboardShortcutsListProps = { onClose: () => unknown }
214 | export const KeyboardShortcutsList: React.FC = ({
215 | onClose,
216 | }) => {
217 | return (
218 |
261 | )
262 | }
263 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useCursor'
2 | export * from './useImage'
3 | export * from './useResize'
4 | export * from './useDeepCompareEffect'
5 | export * from './useLinkedRecords'
6 | export * from './useAnnotation'
7 | export * from './useSettings'
8 | export * from './useHotkeys'
9 | export * from './useStyle'
10 | export * from './useRecords'
11 |
--------------------------------------------------------------------------------
/src/hooks/useAnnotation.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import { TableOrViewQueryResult } from '@airtable/blocks/models'
4 | import {
5 | useBase,
6 | useLoadable,
7 | useRecordById,
8 | useWatchable,
9 | } from '@airtable/blocks/ui'
10 | import is from '@sindresorhus/is'
11 |
12 | import { Annotation } from '../Annotation'
13 | import { useSettings } from '../hooks'
14 | import { Attachment } from './useRecords'
15 |
16 | function binarySearchRecords(
17 | queryResult: TableOrViewQueryResult,
18 | searchValue: string
19 | ) {
20 | searchValue = searchValue?.toLowerCase()
21 | const recordIds = queryResult.recordIds
22 | if (is.nullOrUndefined(searchValue) || !is.nonEmptyArray(recordIds)) {
23 | return null
24 | }
25 |
26 | let maxIndex = recordIds.length - 1,
27 | minIndex = 0,
28 | currentIndex = 0
29 |
30 | while (minIndex <= maxIndex) {
31 | currentIndex = Math.floor((minIndex + maxIndex) / 2)
32 |
33 | const record = queryResult.getRecordByIdIfExists(recordIds[currentIndex])
34 | let name = record?.getCellValue(record.parentTable.primaryField) as string
35 | name = name?.toLowerCase()
36 |
37 | if (name === searchValue) {
38 | return record
39 | } else if (name < searchValue) {
40 | minIndex = currentIndex + 1
41 | } else {
42 | maxIndex = currentIndex - 1
43 | }
44 | }
45 |
46 | return null
47 | }
48 |
49 | export const useAnnotation = (attachment: Attachment | null | undefined) => {
50 | const base = useBase()
51 | const { annotationsTableId, storageFieldId } = useSettings()
52 | const annotationTable = base.getTableByIdIfExists(annotationsTableId || '')
53 |
54 | const queryResultImageIds = annotationTable?.selectRecords({
55 | fields: [annotationTable.primaryField],
56 | sorts: [{ field: annotationTable.primaryField, direction: 'asc' }],
57 | })
58 |
59 | useLoadable(queryResultImageIds || null)
60 | useWatchable(queryResultImageIds, ['records'])
61 |
62 | const annotationRecordId = useMemo(() => {
63 | if (queryResultImageIds && attachment?.id) {
64 | const record = binarySearchRecords(queryResultImageIds, attachment?.id)
65 | return record?.id
66 | }
67 | // eslint-disable-next-line react-hooks/exhaustive-deps
68 | }, [attachment?.id, queryResultImageIds, queryResultImageIds?.recordIds])
69 |
70 | const annotationRecord = useRecordById(
71 | annotationTable!,
72 | annotationRecordId || ''
73 | )
74 |
75 | return useMemo(() => {
76 | const storageField = annotationTable?.getFieldByIdIfExists(
77 | storageFieldId || ''
78 | )
79 |
80 | if (attachment && storageField) {
81 | return new Annotation(
82 | annotationRecord,
83 | attachment,
84 | annotationTable,
85 | storageField
86 | )
87 | } else {
88 | return null
89 | }
90 | }, [annotationTable, annotationRecord, attachment, storageFieldId])
91 | }
92 |
--------------------------------------------------------------------------------
/src/hooks/useCursor.ts:
--------------------------------------------------------------------------------
1 | import { cursor } from '@airtable/blocks'
2 | import { useBase, useLoadable, useWatchable } from '@airtable/blocks/ui'
3 |
4 | import { useRecordsByIds } from './useRecords'
5 |
6 | export const useCursor = () => {
7 | useLoadable(cursor)
8 | useWatchable(cursor, ['selectedRecordIds', 'activeTableId', 'activeViewId'])
9 |
10 | const base = useBase()
11 | const table = base.getTableByIdIfExists(cursor.activeTableId!)
12 |
13 | const selectedRecords = useRecordsByIds(table!, cursor.selectedRecordIds)
14 |
15 | return { base, table, selectedRecords, cursor }
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/useDeepCompareEffect.ts:
--------------------------------------------------------------------------------
1 | import { DependencyList, EffectCallback, useEffect, useRef } from 'react'
2 | import isDeepEqual from 'react-fast-compare'
3 |
4 | export const useDeepCompareEffect = (
5 | callback: EffectCallback,
6 | dependencies: DependencyList
7 | ) => {
8 | const ref = useRef()
9 |
10 | if (!isDeepEqual(dependencies, ref.current)) {
11 | ref.current = dependencies
12 | }
13 |
14 | // eslint-disable-next-line react-hooks/exhaustive-deps
15 | useEffect(callback, ref.current)
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/useHotkeys.ts:
--------------------------------------------------------------------------------
1 | import hotkeys, { HotkeysEvent } from 'hotkeys-js'
2 | import { useCallback, useEffect } from 'react'
3 |
4 | export function useHotkeys(
5 | key?: string | string[],
6 | callback?: (
7 | keyboardEvent: KeyboardEvent,
8 | hotkeysEvent: HotkeysEvent
9 | ) => unknown,
10 | deps?: any[]
11 | ): void {
12 | // eslint-disable-next-line react-hooks/exhaustive-deps
13 | const memoisedCallback = useCallback(
14 | callback ? callback : () => {},
15 | deps || []
16 | )
17 |
18 | const keys = Array.isArray(key) ? key.join() : key
19 |
20 | useEffect(() => {
21 | if (keys) {
22 | const callback = (
23 | keyboardEvent: KeyboardEvent,
24 | HotkeysEvent: HotkeysEvent
25 | ) => {
26 | keyboardEvent.preventDefault()
27 | keyboardEvent.stopPropagation()
28 | memoisedCallback(keyboardEvent, HotkeysEvent)
29 | }
30 | hotkeys(keys, {}, callback)
31 | return () => hotkeys.unbind(keys, callback)
32 | }
33 | }, [memoisedCallback, keys])
34 | }
35 |
--------------------------------------------------------------------------------
/src/hooks/useImage.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { useEffect, useState } from 'react'
3 |
4 | export const useImage = (imageUrl: string | undefined | null) => {
5 | const [image, setImage] = useState(null)
6 |
7 | useEffect(() => {
8 | if (imageUrl)
9 | fabric.Image.fromURL(
10 | imageUrl,
11 | (newImage) => {
12 | setImage(newImage)
13 | },
14 | { excludeFromExport: true, crossOrigin: 'anonymous' }
15 | )
16 | }, [imageUrl])
17 |
18 | return [image]
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/useLinkedRecords.ts:
--------------------------------------------------------------------------------
1 | import flatMap from 'lodash/flatMap'
2 | import { useMemo } from 'react'
3 |
4 | import { Record } from '@airtable/blocks/models'
5 | import { useLoadable } from '@airtable/blocks/ui'
6 |
7 | import { recordLinksOptions } from '../types'
8 | import { isDefined } from '../utils'
9 |
10 | export const useLinkedRecords = (
11 | records?: Record[] | null,
12 | tableId?: string | null
13 | ): Record[] | null => {
14 | const linkedRecordsQueryResults = useMemo(() => {
15 | return flatMap(records, (record) => {
16 | if (record && !record.isDeleted && !record.parentTable.isDeleted)
17 | return record.parentTable.fields.map((field) => {
18 | const options = field.options as recordLinksOptions
19 | if (
20 | !field.isDeleted &&
21 | field.type === 'multipleRecordLinks' &&
22 | options.linkedTableId === tableId
23 | ) {
24 | return record?.selectLinkedRecordsFromCell(field)
25 | }
26 | })
27 | }).filter(isDefined)
28 | }, [records, tableId])
29 |
30 | useLoadable(linkedRecordsQueryResults, { shouldSuspend: false })
31 |
32 | return useMemo(
33 | () =>
34 | flatMap(linkedRecordsQueryResults, (result) =>
35 | result.isDataLoaded ? result.records : []
36 | ),
37 | [
38 | linkedRecordsQueryResults,
39 | // eslint-disable-next-line react-hooks/exhaustive-deps
40 | linkedRecordsQueryResults.every((result) => result.isDataLoaded),
41 | ]
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/hooks/useRecords.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 |
3 | import { SingleRecordQueryResultOpts } from '@airtable/blocks/dist/types/src/models/record_query_result'
4 | import { AttachmentData } from '@airtable/blocks/dist/types/src/types/attachment'
5 | import {
6 | Record,
7 | Table,
8 | TableOrViewQueryResult,
9 | View,
10 | } from '@airtable/blocks/models'
11 | import { useLoadable, useWatchable } from '@airtable/blocks/ui'
12 | import is from '@sindresorhus/is'
13 |
14 | import { LookupOptions } from '../types'
15 |
16 | export function useRecordsByIds(
17 | tableOrView: Table | View,
18 | recordsIds: string[],
19 | opts?: SingleRecordQueryResultOpts
20 | ): Record[] | null {
21 | let queryResult: TableOrViewQueryResult | null = null
22 | if (tableOrView instanceof Table || tableOrView instanceof View) {
23 | queryResult = tableOrView.selectRecords(opts)
24 | }
25 |
26 | useLoadable(queryResult)
27 | useWatchable(queryResult, ['records', 'recordColors'])
28 |
29 | const records = useMemo(() => {
30 | const records: Record[] = []
31 | recordsIds.map((recordId) => {
32 | const record = queryResult?.getRecordByIdIfExists(recordId)
33 | if (record) records.push(record)
34 | })
35 | return records
36 | // eslint-disable-next-line react-hooks/exhaustive-deps
37 | }, [recordsIds.join()])
38 |
39 | useWatchable(records, ['cellValues'])
40 |
41 | return records
42 | }
43 |
44 | export type Attachment = AttachmentData & {
45 | record: Record
46 | attachmentId: string
47 | }
48 |
49 | export const useRecordsAttachments = (
50 | table?: Table,
51 | records?: Record[] | null
52 | ): Attachment[] | null => {
53 | return useMemo(() => {
54 | if (!table || !is.nonEmptyArray(records)) return null
55 |
56 | const attachmentsFields = table.fields.filter((field) => {
57 | const options = field.options as LookupOptions
58 | return (
59 | field.type === 'multipleAttachments' ||
60 | (field.type === 'multipleLookupValues' &&
61 | options.isValid &&
62 | options.result?.type === 'multipleAttachments')
63 | )
64 | })
65 |
66 | const values: Attachment[] = []
67 |
68 | if (records)
69 | for (let record of records) {
70 | for (let field of attachmentsFields) {
71 | const cellValue = record.getCellValue(field)
72 |
73 | if (is.array(cellValue)) {
74 | for (let attachment of cellValue) {
75 | values.push({
76 | ...attachment,
77 | record,
78 | attachmentId: attachment.id,
79 | id: `${record.id}_${attachment.id}`,
80 | })
81 | }
82 | }
83 | }
84 | }
85 | return values
86 | }, [records, table])
87 | }
88 |
--------------------------------------------------------------------------------
/src/hooks/useResize.ts:
--------------------------------------------------------------------------------
1 | import throttle from 'lodash/throttle'
2 | import { DependencyList, useEffect } from 'react'
3 |
4 | export const useResize = (
5 | fun: () => void,
6 | deps?: DependencyList,
7 | time: number = 300
8 | ) => {
9 | useEffect(() => {
10 | const throttledFunction = throttle(fun, time)
11 | throttledFunction() // run on load
12 | window.addEventListener('resize', throttledFunction)
13 | return () => window.removeEventListener('resize', throttledFunction)
14 | // eslint-disable-next-line react-hooks/exhaustive-deps
15 | }, deps)
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/useSettings.ts:
--------------------------------------------------------------------------------
1 | import mapValues from 'lodash/mapValues'
2 |
3 | import { useGlobalConfig } from '@airtable/blocks/ui'
4 | import is from '@sindresorhus/is'
5 |
6 | import { globalConfigKeys } from '../Settings'
7 |
8 | export const useSettings = () => {
9 | const globalConfig = useGlobalConfig()
10 | return mapValues(globalConfigKeys, (key) => {
11 | const value = globalConfig.get(key)
12 | return is.string(value) ? value : null
13 | })
14 | }
15 |
--------------------------------------------------------------------------------
/src/hooks/useStyle.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions } from 'fabric/fabric-impl'
3 | import pick from 'lodash/pick'
4 | import { useCallback, useRef, useState } from 'react'
5 |
6 | import { colors, colorUtils } from '@airtable/blocks/ui'
7 | import is from '@sindresorhus/is/dist'
8 |
9 | import { CanvasTool } from '../tools'
10 | import { updateActiveObjectsStyles as updateActiveLayersStyles } from '../utils'
11 |
12 | const filteredStyles = [
13 | 'stroke',
14 | 'strokeDashArray',
15 | 'strokeWidth',
16 | 'fill',
17 | 'fontSize',
18 | 'fontWeight',
19 | 'fontStyle',
20 | 'underline',
21 | 'textBackgroundColor',
22 | ]
23 |
24 | export const DEFAULT_COLOR = colors.RED_BRIGHT
25 |
26 | export const defaultStyle: IObjectOptions = {
27 | stroke: colorUtils.getHexForColor(DEFAULT_COLOR)!,
28 | strokeWidth: 8,
29 | fill: 'transparent',
30 | }
31 |
32 | export const useStyle = (
33 | activeTool: CanvasTool | null,
34 | canvas: fabric.Canvas | null
35 | ) => {
36 | const [activeStyle, _setActiveStyle] = useState(defaultStyle)
37 | const [activeLayer, setActiveLayer] = useState(null)
38 | const defaultStyles = useRef(defaultStyle)
39 |
40 | const updateActiveStyle = useCallback(
41 | (newStyleValue: fabric.IObjectOptions) => {
42 | let newFilteredStyle = pick(newStyleValue, filteredStyles)
43 | if (is.nonEmptyObject(newFilteredStyle)) {
44 | const fill = newFilteredStyle.fill || 'transparent'
45 | newFilteredStyle = { ...newFilteredStyle, fill }
46 | _setActiveStyle(newFilteredStyle)
47 | activeTool?.configureCanvas?.(newFilteredStyle)
48 | return newFilteredStyle
49 | }
50 | },
51 | [activeTool]
52 | )
53 |
54 | const updateUserStyles = useCallback(
55 | (newStyleValue: fabric.IObjectOptions) => {
56 | let newFilteredStyle = updateActiveStyle(newStyleValue)
57 | if (is.nonEmptyObject(newFilteredStyle)) {
58 | updateActiveLayersStyles(canvas, newFilteredStyle)
59 | }
60 | },
61 | [updateActiveStyle, canvas]
62 | )
63 |
64 | const setSelectedLayerStyle = useCallback(
65 | (event?: fabric.IEvent) => {
66 | const selectedLayer = event?.selected?.[0]
67 | if (
68 | event?.selected?.length === 1 &&
69 | selectedLayer instanceof fabric.Object &&
70 | !(selectedLayer instanceof fabric.Group)
71 | ) {
72 | setActiveLayer(selectedLayer)
73 |
74 | const layerStyles: IObjectOptions = selectedLayer.toObject()
75 |
76 | const fill = is.nonEmptyString(layerStyles.fill)
77 | ? layerStyles.fill
78 | : 'transparent'
79 |
80 | if (!defaultStyles.current) {
81 | defaultStyles.current = activeStyle
82 | }
83 | updateActiveStyle(pick({ ...layerStyles, fill }, filteredStyles))
84 | } else if (defaultStyles.current) {
85 | setActiveLayer(null)
86 | updateActiveStyle(defaultStyles.current)
87 | defaultStyles.current = null
88 | } else {
89 | setActiveLayer(null)
90 | }
91 | },
92 | [activeStyle, updateActiveStyle]
93 | )
94 |
95 | return {
96 | activeLayer,
97 | activeStyle,
98 | updateUserStyles,
99 | setSelectedLayer: setSelectedLayerStyle,
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import './styles'
2 |
3 | import React from 'react'
4 |
5 | import { initializeBlock } from '@airtable/blocks/ui'
6 |
7 | import { Main } from './Main'
8 |
9 | initializeBlock(() => )
10 |
--------------------------------------------------------------------------------
/src/styles.ts:
--------------------------------------------------------------------------------
1 | import { loadCSSFromString } from '@airtable/blocks/ui'
2 |
3 | // emoji-mart/css/emoji-mart.css
4 | loadCSSFromString(
5 | '.emoji-mart,.emoji-mart *{box-sizing:border-box;line-height:1.15}.emoji-mart{font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",sans-serif;font-size:16px;display:inline-block;color:#222427;border:1px solid #d9d9d9;border-radius:5px;background:#fff}.emoji-mart .emoji-mart-emoji{padding:6px}.emoji-mart-bar{border:0 solid #d9d9d9}.emoji-mart-bar:first-child{border-bottom-width:1px;border-top-left-radius:5px;border-top-right-radius:5px}.emoji-mart-bar:last-child{position:absolute;right:5px;top:34px;border:none;}.emoji-mart-anchors{display:flex;flex-direction:row;justify-content:space-between;padding:0 6px;line-height:0}.emoji-mart-anchor{position:relative;display:block;flex:1 1 auto;color:#858585;text-align:center;padding:12px 4px;overflow:hidden;transition:color .1s ease-out;margin:0;box-shadow:none;background:0 0;border:none}.emoji-mart-anchor:focus{outline:0}.emoji-mart-anchor-selected,.emoji-mart-anchor:focus,.emoji-mart-anchor:hover{color:#464646}.emoji-mart-anchor-selected .emoji-mart-anchor-bar{bottom:0}.emoji-mart-anchor-bar{position:absolute;bottom:-3px;left:0;width:100%;height:3px;background-color:#464646}.emoji-mart-anchors i{display:inline-block;width:100%;max-width:22px}.emoji-mart-anchors img,.emoji-mart-anchors svg{fill:currentColor;height:18px;width:18px}.emoji-mart-scroll{overflow-y:scroll;overflow-x:hidden;height:270px;padding:0 6px 6px 6px;will-change:transform}.emoji-mart-search{margin-top:6px;padding:0 6px;position:relative}.emoji-mart-search input{font-size:16px;display:block;width:100%;padding:5px 25px 6px 10px;border-radius:5px;border:1px solid #d9d9d9;outline:0}.emoji-mart-search input,.emoji-mart-search input::-webkit-search-cancel-button,.emoji-mart-search input::-webkit-search-decoration,.emoji-mart-search input::-webkit-search-results-button,.emoji-mart-search input::-webkit-search-results-decoration{-webkit-appearance:none}.emoji-mart-search-icon{position:absolute;top:7px;right:11px;z-index:2;padding:2px 5px 1px;border:none;background:0 0}.emoji-mart-category .emoji-mart-emoji span{z-index:1;position:relative;text-align:center;cursor:default}.emoji-mart-category .emoji-mart-emoji:hover:before{z-index:0;content:"";position:absolute;top:0;left:0;width:100%;height:100%;background-color:#f4f4f4;border-radius:100%}.emoji-mart-category-label{z-index:2;position:relative;position:-webkit-sticky;position:sticky;top:0}.emoji-mart-category-label span{display:block;width:100%;font-weight:500;padding:5px 6px;background-color:#fff;background-color:rgba(255,255,255,.95)}.emoji-mart-category-list{margin:0;padding:0}.emoji-mart-category-list li{list-style:none;margin:0;padding:0;display:inline-block}.emoji-mart-emoji{position:relative;display:inline-block;font-size:0;margin:0;padding:0;border:none;background:0 0;box-shadow:none}.emoji-mart-emoji-native{font-family:"Segoe UI Emoji","Segoe UI Symbol","Segoe UI","Apple Color Emoji","Twemoji Mozilla","Noto Color Emoji","Android Emoji"}.emoji-mart-no-results{font-size:14px;text-align:center;padding-top:70px;color:#858585}.emoji-mart-no-results-img{display:block;margin-left:auto;margin-right:auto;width:50%}.emoji-mart-no-results .emoji-mart-category-label{display:none}.emoji-mart-no-results .emoji-mart-no-results-label{margin-top:.2em}.emoji-mart-no-results .emoji-mart-emoji:hover:before{content:none}.emoji-mart-preview{position:relative;height:70px}.emoji-mart-preview-data,.emoji-mart-preview-emoji,.emoji-mart-preview-skins{position:absolute;top:50%;transform:translateY(-50%)}.emoji-mart-preview-emoji{left:12px}.emoji-mart-preview-data{left:68px;right:12px;word-break:break-all}.emoji-mart-preview-skins{right:30px;text-align:right}.emoji-mart-preview-skins.custom{right:10px;text-align:right}.emoji-mart-preview-name{font-size:14px}.emoji-mart-preview-shortname{font-size:12px;color:#888}.emoji-mart-preview-emoticon+.emoji-mart-preview-emoticon,.emoji-mart-preview-shortname+.emoji-mart-preview-emoticon,.emoji-mart-preview-shortname+.emoji-mart-preview-shortname{margin-left:.5em}.emoji-mart-preview-emoticon{font-size:11px;color:#bbb}.emoji-mart-title span{display:inline-block;vertical-align:middle}.emoji-mart-title .emoji-mart-emoji{padding:0}.emoji-mart-title-label{color:#999a9c;font-size:26px;font-weight:300}.emoji-mart-skin-swatches{font-size:0;padding:2px 0;border:1px solid #d9d9d9;border-radius:12px;background-color:#fff}.emoji-mart-skin-swatches.custom{font-size:0;border:none;background-color:#fff}.emoji-mart-skin-swatches.opened .emoji-mart-skin-swatch{width:16px;padding:0 2px}.emoji-mart-skin-swatches.opened .emoji-mart-skin-swatch.selected:after{opacity:.75}.emoji-mart-skin-swatch{display:inline-block;width:0;vertical-align:middle;transition-property:width,padding;transition-duration:.125s;transition-timing-function:ease-out}.emoji-mart-skin-swatch:nth-child(1){transition-delay:0s}.emoji-mart-skin-swatch:nth-child(2){transition-delay:.03s}.emoji-mart-skin-swatch:nth-child(3){transition-delay:.06s}.emoji-mart-skin-swatch:nth-child(4){transition-delay:.09s}.emoji-mart-skin-swatch:nth-child(5){transition-delay:.12s}.emoji-mart-skin-swatch:nth-child(6){transition-delay:.15s}.emoji-mart-skin-swatch.selected{position:relative;width:16px;padding:0 2px}.emoji-mart-skin-swatch.selected:after{content:"";position:absolute;top:50%;left:50%;width:4px;height:4px;margin:-2px 0 0 -2px;background-color:#fff;border-radius:100%;pointer-events:none;opacity:0;transition:opacity .2s ease-out}.emoji-mart-skin-swatch.custom{display:inline-block;width:0;height:38px;overflow:hidden;vertical-align:middle;transition-property:width,height;transition-duration:.125s;transition-timing-function:ease-out;cursor:default}.emoji-mart-skin-swatch.custom.selected{position:relative;width:36px;height:38px;padding:0 2px 0 0}.emoji-mart-skin-swatch.custom.selected:after{content:"";width:0;height:0}.emoji-mart-skin-swatches.custom .emoji-mart-skin-swatch.custom:hover{background-color:#f4f4f4;border-radius:10%}.emoji-mart-skin-swatches.custom.opened .emoji-mart-skin-swatch.custom{width:36px;height:38px;padding:0 2px 0 0}.emoji-mart-skin-swatches.custom.opened .emoji-mart-skin-swatch.custom.selected:after{opacity:.75}.emoji-mart-skin-text.opened{display:inline-block;vertical-align:middle;text-align:left;color:#888;font-size:11px;padding:5px 2px;width:95px;height:40px;border-radius:10%;background-color:#fff}.emoji-mart-skin{display:inline-block;width:100%;padding-top:100%;max-width:12px;border-radius:100%}.emoji-mart-skin-tone-1{background-color:#ffc93a}.emoji-mart-skin-tone-2{background-color:#fadcbc}.emoji-mart-skin-tone-3{background-color:#e0bb95}.emoji-mart-skin-tone-4{background-color:#bf8f68}.emoji-mart-skin-tone-5{background-color:#9b643d}.emoji-mart-skin-tone-6{background-color:#594539}.emoji-mart-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}'
6 | )
7 |
8 | // rc-notification/assets/index.css
9 | loadCSSFromString(`
10 | .rc-notification {
11 | position: fixed;
12 | z-index: 1000;
13 | }
14 | .rc-notification-notice {
15 | padding: 7px 20px 7px 10px;
16 | border-radius: 3px 3px;
17 | border: 1px solid #999;
18 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
19 | border: 0px solid rgba(0, 0, 0, 0);
20 | background: #fff;
21 | display: block;
22 | width: auto;
23 | line-height: 1.5;
24 | position: relative;
25 | margin: 10px 0;
26 | }
27 | .rc-notification-notice-closable {
28 | padding-right: 20px;
29 | }
30 | .rc-notification-notice-close {
31 | position: absolute;
32 | right: 5px;
33 | top: 3px;
34 | color: #000;
35 | cursor: pointer;
36 | outline: none;
37 | font-size: 16px;
38 | font-weight: 700;
39 | line-height: 1;
40 | text-shadow: 0 1px 0 #fff;
41 | filter: alpha(opacity=20);
42 | opacity: 0.2;
43 | text-decoration: none;
44 | }
45 | .rc-notification-notice-close-x:after {
46 | content: '×';
47 | }
48 | .rc-notification-notice-close:hover {
49 | opacity: 1;
50 | filter: alpha(opacity=100);
51 | text-decoration: none;
52 | }
53 | .rc-notification-fade-enter {
54 | opacity: 0;
55 | animation-duration: 0.3s;
56 | animation-fill-mode: both;
57 | animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2);
58 | animation-play-state: paused;
59 | }
60 | .rc-notification-fade-leave {
61 | animation-duration: 0.3s;
62 | animation-fill-mode: both;
63 | animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2);
64 | animation-play-state: paused;
65 | }
66 | .rc-notification-fade-enter.rc-notification-fade-enter-active {
67 | animation-name: rcNotificationFadeIn;
68 | animation-play-state: running;
69 | }
70 | .rc-notification-fade-leave.rc-notification-fade-leave-active {
71 | animation-name: rcDialogFadeOut;
72 | animation-play-state: running;
73 | }
74 | @keyframes rcNotificationFadeIn {
75 | 0% {
76 | opacity: 0;
77 | }
78 | 100% {
79 | opacity: 1;
80 | }
81 | }
82 | @keyframes rcDialogFadeOut {
83 | 0% {
84 | opacity: 1;
85 | }
86 | 100% {
87 | opacity: 0;
88 | }
89 | }
90 | `)
91 |
--------------------------------------------------------------------------------
/src/tools/Arrow.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions } from 'fabric/fabric-impl'
3 |
4 | import { CanvasTool } from './tool'
5 |
6 | export class Arrow extends CanvasTool {
7 | name = 'Arrow'
8 |
9 | private isDown = false
10 | private line?: fabric.Line
11 | private head?: fabric.Triangle
12 |
13 | configureCanvas(props: IObjectOptions) {
14 | this.canvas.isDrawingMode = this.canvas.selection = false
15 | this.canvas.defaultCursor = 'crosshair'
16 | this.canvas.forEachObject(
17 | (object) => (object.selectable = object.evented = false)
18 | )
19 | this.props = props
20 | }
21 |
22 | onMouseDown(event: fabric.IEvent) {
23 | this.isDown = true
24 | if (!this.props) return
25 | const { strokeDashArray, strokeDashOffset } = this.props
26 |
27 | const stroke = this.props.stroke || '#000000'
28 | const strokeWidth = this.props.strokeWidth || 8
29 | const pointer = this.canvas.getPointer(event.e)
30 | const points = [pointer.x, pointer.y, pointer.x, pointer.y]
31 | this.line = new fabric.Line(points, {
32 | strokeWidth: strokeWidth,
33 | fill: stroke,
34 | stroke: stroke,
35 | originX: 'center',
36 | originY: 'center',
37 | selectable: false,
38 | evented: false,
39 | strokeDashArray,
40 | strokeDashOffset,
41 | })
42 |
43 | this.head = new fabric.Triangle({
44 | fill: stroke,
45 | left: pointer.x,
46 | top: pointer.y,
47 | originX: 'center',
48 | originY: 'center',
49 | height: 3 * strokeWidth,
50 | width: 3 * strokeWidth,
51 | selectable: false,
52 | evented: false,
53 | angle: 90,
54 | })
55 |
56 | this.canvas.add(this.line, this.head)
57 | }
58 |
59 | onMouseMove(event: fabric.IEvent) {
60 | if (!this.isDown || !this.line || !this.head) return
61 | const pointer = this.canvas.getPointer(event.e)
62 | this.line.set({ x2: pointer.x, y2: pointer.y })
63 | this.line.setCoords()
64 |
65 | const x_delta = pointer.x - (this.line.x1 || 0)
66 | const y_delta = pointer.y - (this.line.y1 || 0)
67 |
68 | this.head.set({
69 | left: pointer.x,
70 | top: pointer.y,
71 | angle: 90 + (Math.atan2(y_delta, x_delta) * 180) / Math.PI,
72 | })
73 |
74 | this.canvas.requestRenderAll()
75 | }
76 |
77 | onMouseUp() {
78 | this.isDown = false
79 |
80 | if (!this.line || !this.head) return
81 |
82 | this.canvas.remove(this.line, this.head)
83 | const arrow = new fabric.Group([this.line, this.head], {
84 | shape: 'arrow',
85 | })
86 | this.canvas.add(arrow)
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/tools/Circle.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions } from 'fabric/fabric-impl'
3 |
4 | import { CanvasTool } from './tool'
5 |
6 | export class Circle extends CanvasTool {
7 | name = 'Circle'
8 |
9 | private isDown?: boolean
10 | private startX?: number
11 | private startY?: number
12 | private ellipse?: fabric.Ellipse
13 |
14 | configureCanvas(props: IObjectOptions) {
15 | this.canvas.isDrawingMode = this.canvas.selection = false
16 | this.canvas.defaultCursor = 'crosshair'
17 | this.canvas.forEachObject(
18 | (object) => (object.selectable = object.evented = false)
19 | )
20 | this.props = props
21 | }
22 |
23 | onMouseDown(event: fabric.IEvent) {
24 | this.isDown = true
25 | let pointer = this.canvas.getPointer(event.e)
26 | this.startX = pointer.x
27 | this.startY = pointer.y
28 | this.ellipse = new fabric.Ellipse({
29 | ...this.props,
30 | left: this.startX,
31 | top: this.startY,
32 | originX: 'left',
33 | originY: 'center',
34 | rx: 1,
35 | ry: 1,
36 | })
37 | this.canvas.add(this.ellipse)
38 | }
39 |
40 | onMouseMove(event: fabric.IEvent) {
41 | if (!this.isDown || !this.ellipse || !this.startY || !this.startX) return
42 | const pointer = this.canvas.getPointer(event.e)
43 |
44 | this.ellipse.set({
45 | rx: Math.abs(this.startX - pointer.x) / 2,
46 | ry: Math.abs(this.startY - pointer.y) / 2,
47 | originX: this.startX > pointer.x ? 'right' : 'left',
48 | originY: this.startY > pointer.y ? 'bottom' : 'top',
49 | })
50 | this.ellipse.setCoords()
51 |
52 | this.canvas.requestRenderAll()
53 | }
54 |
55 | onMouseUp() {
56 | this.isDown = false
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/tools/Line.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions } from 'fabric/fabric-impl'
3 |
4 | import { CanvasTool } from './tool'
5 |
6 | export class Line extends CanvasTool {
7 | name = 'Line'
8 |
9 | private isDown?: boolean
10 | private line?: fabric.Line
11 |
12 | configureCanvas(props: IObjectOptions) {
13 | this.canvas.isDrawingMode = this.canvas.selection = false
14 | this.canvas.defaultCursor = 'crosshair'
15 | this.canvas.forEachObject(
16 | (object) => (object.selectable = object.evented = false)
17 | )
18 | this.props = props
19 | }
20 |
21 | onMouseDown(event: fabric.IEvent) {
22 | this.isDown = true
23 | const pointer = this.canvas.getPointer(event.e)
24 | const points = [pointer.x, pointer.y, pointer.x, pointer.y]
25 | this.line = new fabric.Line(points, {
26 | ...this.props,
27 | originX: 'center',
28 | originY: 'center',
29 | })
30 | this.canvas.add(this.line)
31 | }
32 |
33 | onMouseMove(event: fabric.IEvent) {
34 | if (!this.isDown) return
35 | const pointer = this.canvas.getPointer(event.e)
36 | this.line?.set({ x2: pointer.x, y2: pointer.y })
37 | this.line?.setCoords()
38 | this.canvas.requestRenderAll()
39 | }
40 |
41 | onMouseUp() {
42 | this.isDown = false
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/tools/Move.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 |
3 | import { resetViewport } from '../utils'
4 | import { CanvasTool } from './tool'
5 |
6 | export class Move extends CanvasTool {
7 | name = 'Move'
8 |
9 | private isDown?: boolean
10 | private startX?: number
11 | private startY?: number
12 |
13 | configureCanvas() {
14 | this.canvas.isDrawingMode = false
15 | this.canvas.selection = false
16 | this.canvas.forEachObject((o) => (o.selectable = o.evented = false))
17 | this.canvas.defaultCursor = 'move'
18 | }
19 |
20 | onMouseDown(event: fabric.IEvent) {
21 | this.isDown = true
22 | const pointer = this.canvas.getPointer(event.e)
23 | this.startX = pointer.x
24 | this.startY = pointer.y
25 | }
26 |
27 | onMouseMove(event: fabric.IEvent) {
28 | if (!this.isDown || !this.startX || !this.startY) return
29 | const pointer = this.canvas.getPointer(event.e)
30 |
31 | const point = new fabric.Point(
32 | pointer.x - this.startX,
33 | pointer.y - this.startY
34 | )
35 |
36 | this.canvas.relativePan(point)
37 | resetViewport(this.canvas)
38 |
39 | this.canvas.requestRenderAll()
40 | }
41 |
42 | onMouseUp() {
43 | this.isDown = false
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/tools/Pencil.ts:
--------------------------------------------------------------------------------
1 | import { IObjectOptions } from 'fabric/fabric-impl'
2 |
3 | import is from '@sindresorhus/is'
4 |
5 | import { CanvasTool } from './tool'
6 |
7 | export class Pencil extends CanvasTool {
8 | name = 'Pencil'
9 |
10 | onMouseDown: undefined
11 | onMouseMove: undefined
12 | onMouseUp: undefined
13 |
14 | configureCanvas(props: IObjectOptions) {
15 | const { stroke, strokeWidth = 4, fill } = props
16 | this.canvas.isDrawingMode = true
17 | this.canvas.freeDrawingBrush.width = strokeWidth
18 | this.canvas.freeDrawingBrush.color =
19 | stroke || (is.string(fill) ? fill : 'white')
20 | //@ts-ignore
21 | this.canvas.freeDrawingBrush.decimate = 5
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/tools/Rectangle.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions } from 'fabric/fabric-impl'
3 |
4 | import { CanvasTool } from './tool'
5 |
6 | export class Rectangle extends CanvasTool {
7 | name = 'Rectangle'
8 |
9 | private isDown?: boolean
10 | private startX?: number
11 | private startY?: number
12 | private rect?: fabric.Rect
13 |
14 | configureCanvas(props: IObjectOptions) {
15 | this.canvas.isDrawingMode = this.canvas.selection = false
16 | this.canvas.defaultCursor = 'crosshair'
17 | this.canvas.forEachObject(
18 | (object) => (object.selectable = object.evented = false)
19 | )
20 | this.props = props
21 | }
22 |
23 | onMouseDown(event: fabric.IEvent) {
24 | this.isDown = true
25 | let pointer = this.canvas.getPointer(event.e)
26 | this.startX = pointer.x
27 | this.startY = pointer.y
28 | this.rect = new fabric.Rect({
29 | ...this.props,
30 | left: this.startX,
31 | top: this.startY,
32 | originX: 'left',
33 | originY: 'top',
34 | width: pointer.x - this.startX,
35 | height: pointer.y - this.startY,
36 | rx: 2.5,
37 | ry: 2.5,
38 | })
39 | this.canvas.add(this.rect)
40 | }
41 |
42 | onMouseMove(event: fabric.IEvent) {
43 | if (!this.isDown || !this.startX || !this.startY || !this.rect) return
44 | let pointer = this.canvas.getPointer(event.e)
45 | if (this.startX > pointer.x) {
46 | this.rect.set({ left: Math.abs(pointer.x) })
47 | }
48 | if (this.startY > pointer.y) {
49 | this.rect.set({ top: Math.abs(pointer.y) })
50 | }
51 | this.rect.set({ width: Math.abs(this.startX - pointer.x) })
52 | this.rect.set({ height: Math.abs(this.startY - pointer.y) })
53 | this.rect.setCoords()
54 | this.canvas.requestRenderAll()
55 | }
56 |
57 | onMouseUp() {
58 | this.isDown = false
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/tools/Select.ts:
--------------------------------------------------------------------------------
1 | import { CanvasTool } from './tool'
2 |
3 | export class Select extends CanvasTool {
4 | name = 'Select'
5 |
6 | onMouseDown: undefined
7 | onMouseMove: undefined
8 | onMouseUp: undefined
9 |
10 | configureCanvas() {
11 | this.canvas.isDrawingMode = false
12 | this.canvas.selection = true
13 | this.canvas.defaultCursor = 'default'
14 | this.canvas.forEachObject((object) => {
15 | object.selectable = object.evented = true
16 | })
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/tools/Tool.ts:
--------------------------------------------------------------------------------
1 | import { IObjectOptions } from 'fabric/fabric-impl'
2 |
3 | export abstract class CanvasTool {
4 | constructor(public canvas: fabric.Canvas) {}
5 | abstract name: string
6 | protected props?: IObjectOptions
7 | abstract configureCanvas?(props: IObjectOptions): void
8 | abstract onMouseUp?(event: fabric.IEvent): void
9 | abstract onMouseDown?(event: fabric.IEvent): void
10 | abstract onMouseMove?(event: fabric.IEvent): void
11 | }
12 |
--------------------------------------------------------------------------------
/src/tools/ToolsList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | FiArrowUpRight,
4 | FiCircle,
5 | FiEdit3,
6 | FiMinus,
7 | FiSquare,
8 | } from 'react-icons/fi'
9 |
10 | import { Arrow, Circle, Line, Pencil, Rectangle } from './index'
11 |
12 | export const shapesList = {
13 | Pencil: { label: 'Pencil', class: Pencil, icon: },
14 | Circle: {
15 | label: 'Circle',
16 | class: Circle,
17 | icon: ,
18 | },
19 | Rectangle: {
20 | label: 'Rectangle',
21 | class: Rectangle,
22 | icon: ,
23 | },
24 | Line: { label: 'Line', class: Line, icon: },
25 | Arrow: {
26 | label: 'Arrow',
27 | class: Arrow,
28 | icon: (
29 |
30 | ),
31 | },
32 | } as const
33 |
--------------------------------------------------------------------------------
/src/tools/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Arrow'
2 | export * from './Circle'
3 | export * from './Line'
4 | export * from './Pencil'
5 | export * from './Move'
6 | export * from './Rectangle'
7 | export * from './Select'
8 | export * from './Tool'
9 | export * from './ToolsList'
10 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "es2018",
5 | "sourceMap": true,
6 | "strict": true,
7 | "noImplicitThis": false,
8 | "allowSyntheticDefaultImports": true,
9 | "jsx": "react",
10 | "baseUrl": ".",
11 | "paths": {
12 | "fabric-pure-browser": ["../node_modules/@types/fabric/index"]
13 | }
14 | },
15 | "exclude": ["node_modules"]
16 | }
17 |
--------------------------------------------------------------------------------
/src/types/blocks.d.ts:
--------------------------------------------------------------------------------
1 | import { Table } from '@airtable/blocks/models'
2 |
3 | declare module '@airtable/blocks/models' {
4 | interface Record {
5 | parentTable: Table
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/types/fabric.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace fabric {
2 | interface StaticCanvas {
3 | /** Canvas original dimensions */
4 | originalSize?: { width: number; height: number }
5 | }
6 |
7 | interface IObjectOptions {
8 | /** Collaborator Id - user who created the object */
9 | createdBy?: string
10 | /** Collaborator Id - user that last modified the object */
11 | modifiedBy?: string
12 | /** When object was created in ms */
13 | createdTime?: number
14 | /** When object was last modified in ms */
15 | modifiedTime?: number
16 | }
17 |
18 | interface IGroupOptions {
19 | /** Shape name if the groupe represent a shape like an Arrow */
20 | shape?: string
21 | }
22 |
23 | //TODO: Remove when @types/fabric is updated to v4
24 | interface IEvent {
25 | selected?: fabric.Object[]
26 | deselected?: fabric.Object[]
27 | }
28 | }
29 |
30 | // Fix for "Property 'observable' does not exist on type 'SymbolConstructor'."
31 | declare interface SymbolConstructor {
32 | readonly observable: symbol
33 | }
34 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from 'react'
2 |
3 | import { FieldType } from '@airtable/blocks/dist/types/src/types/field'
4 | import { Button } from '@airtable/blocks/ui'
5 |
6 | type FieldId = string
7 | type TableId = string
8 | type ViewId = string
9 |
10 | export type ButtonProps = ComponentProps
11 |
12 | export type LookupOptions = {
13 | /**
14 | * whether the lookup field is correctly configured
15 | */
16 | isValid: boolean
17 | /**
18 | * the linked record field in this table that this field is
19 | * looking up
20 | */
21 | recordLinkFieldId: FieldId
22 | /**
23 | * the field in the foreign table that will be looked up on
24 | * each linked record
25 | */
26 | fieldIdInLinkedTable: FieldId | null
27 | /**
28 | * the local field configuration for the foreign field being
29 | * looked up
30 | */
31 | result?: undefined | { type: FieldType; options: unknown }
32 | }
33 |
34 | export type recordLinksOptions = {
35 | /**
36 | * The ID of the table this field links to
37 | */
38 | linkedTableId: TableId
39 | /**
40 | * The ID of the field in the linked table that links back
41 | * to this one
42 | */
43 | inverseLinkFieldId?: FieldId
44 | /**
45 | * The ID of the view in the linked table to use when showing
46 | * a list of records to select from
47 | */
48 | viewIdForRecordSelection?: ViewId
49 | /**
50 | * Whether linked records are rendered in the reverse order from the cell value in the
51 | * Airtable UI (i.e. most recent first)
52 | * You generally do not need to rely on this option.
53 | */
54 | isReversed: boolean
55 | }
56 |
--------------------------------------------------------------------------------
/src/utils/canvas.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import { IObjectOptions } from 'fabric/fabric-impl'
3 | import React from 'react'
4 |
5 | import is from '@sindresorhus/is'
6 |
7 | import { confirmDialog, getLayerNameAndIcon } from '../components'
8 |
9 | export const customKeys = [
10 | 'createdBy',
11 | 'modifiedBy',
12 | 'createdTime',
13 | 'modifiedTime',
14 | 'shape',
15 | ]
16 |
17 | export const getCanvasJson = (canvas: fabric.Canvas) => {
18 | if (!canvas) return null
19 | const canvasObject = canvas?.toObject([...customKeys, 'name'])
20 | if (is.emptyArray(canvasObject.objects)) return null
21 | return JSON.stringify(canvasObject)
22 | }
23 |
24 | export const updateActiveObjectsStyles = (
25 | canvas: fabric.Canvas | null,
26 | styles: IObjectOptions
27 | ) => {
28 | if (!canvas) return
29 | const selected = canvas.getActiveObjects()
30 | selected.map((object) => {
31 | if (object instanceof fabric.Textbox) {
32 | object.styles = {}
33 | } else if (object instanceof fabric.Group) {
34 | object.forEachObject((childObject) => {
35 | childObject.setOptions(styles)
36 | })
37 | }
38 | object.setOptions(styles)
39 | object.canvas?.fire('state:modified', { target: object })
40 | })
41 | canvas.requestRenderAll()
42 | }
43 |
44 | export const deleteActiveObjects = (canvas: fabric.Canvas) => {
45 | const activeObjects = canvas.getActiveObjects()
46 |
47 | if (activeObjects.length > 1) {
48 | confirmDialog({
49 | title: 'Delete selected layers?',
50 | body: (
51 |
52 | {activeObjects.map((o, i) => {
53 | const { name } = getLayerNameAndIcon(o)
54 | return - {name}
55 | })}
56 |
57 | ),
58 | isConfirmActionDangerous: true,
59 | onConfirm() {
60 | activeObjects.forEach((object) => canvas.remove(object))
61 | canvas.discardActiveObject()
62 | },
63 | })
64 | } else if (activeObjects[0]) {
65 | canvas.remove(activeObjects[0])
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/utils/imageUtils.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from 'fabric-pure-browser'
2 | import clamp from 'lodash/clamp'
3 |
4 | export const MAX_CANVAS_WIDTH = 1000
5 |
6 | export const downloadCanvasAsImage = (
7 | canvas: fabric.Canvas,
8 | name: string = 'image'
9 | ) => {
10 | const image = canvas.backgroundImage
11 |
12 | let size = { width: 1000, height: 600 }
13 |
14 | if (image instanceof fabric.Image) {
15 | size = image.getOriginalSize()
16 | }
17 |
18 | const scale = size.width / canvas.getWidth() / 2
19 |
20 | const dataUrl = canvas.toDataURL({
21 | enableRetinaScaling: true,
22 | withoutTransform: true,
23 | multiplier: scale,
24 | })
25 |
26 | const link = document.createElement('a')
27 | link.download = name + '.png'
28 | link.href = dataUrl
29 | link.click()
30 | }
31 |
32 | export const calculateMaxSize = (
33 | image: { width: number; height: number },
34 | maxSize: number
35 | ): { width: number; height: number } => {
36 | const largest = Math.max(image.width, image.height)
37 | if (largest < maxSize) return { width: image.width, height: image.height }
38 | const scale = maxSize / largest
39 | return {
40 | width: image.width * scale,
41 | height: image.height * scale,
42 | }
43 | }
44 |
45 | export const resetViewport = (
46 | canvas: fabric.Canvas | fabric.StaticCanvas | null
47 | ) => {
48 | const viewportTransform = canvas?.viewportTransform
49 | const original = canvas?.originalSize
50 | if (canvas && viewportTransform && original) {
51 | const zoom = canvas.getZoom()
52 |
53 | const currentWidthOffset = viewportTransform[4]
54 | const currentHeightOffset = viewportTransform[5]
55 | const maxWidthOffset = canvas.getWidth() - original.width * zoom
56 | const maxHeightOffset = canvas.getHeight() - original.height * zoom
57 |
58 | const widthOffset = clamp(currentWidthOffset, maxWidthOffset, 0)
59 | const heightOffset = clamp(currentHeightOffset, maxHeightOffset, 0)
60 |
61 | if (
62 | widthOffset !== currentWidthOffset ||
63 | heightOffset !== currentHeightOffset
64 | ) {
65 | canvas.setViewportTransform([
66 | ...viewportTransform.slice(0, 4),
67 | widthOffset,
68 | heightOffset,
69 | ])
70 | }
71 | }
72 | }
73 |
74 | type updateAndScaleImageValues = {
75 | canvas: fabric.Canvas | fabric.StaticCanvas | null
76 | image: fabric.Image | null
77 | zoom?: number
78 | zoomToPoint?: fabric.Point
79 | } & (
80 | | { dimensions: { width: number; height: number } }
81 | | { container: HTMLElement }
82 | )
83 |
84 | export const updateAndScaleImage = (args: updateAndScaleImageValues) => {
85 | const { canvas, image, zoom, zoomToPoint } = args
86 | if (!canvas) return
87 |
88 | const containerDimensions =
89 | 'container' in args
90 | ? {
91 | width: args.container.offsetWidth,
92 | height: args.container.offsetHeight,
93 | }
94 | : args.dimensions
95 |
96 | let size = { width: 1000, height: 600 }
97 |
98 | if (image) {
99 | canvas.backgroundImage = image
100 |
101 | size = calculateMaxSize(image.getOriginalSize(), MAX_CANVAS_WIDTH)
102 |
103 | image.scaleToWidth(size.width)
104 | image.scaleToHeight(size.height)
105 | }
106 |
107 | const zoomValue = zoom || 1
108 |
109 | const scale =
110 | Math.min(
111 | containerDimensions.width / size.width,
112 | containerDimensions.height / size.height
113 | ) * zoomValue
114 |
115 | canvas.setDimensions({
116 | height: Math.min(size.height * scale, containerDimensions.height),
117 | width: Math.min(size.width * scale, containerDimensions.width),
118 | })
119 |
120 | const canvasCenter = canvas.getCenter()
121 | canvas.zoomToPoint(
122 | zoomToPoint || new fabric.Point(canvasCenter.left, canvasCenter.top),
123 | scale
124 | )
125 |
126 | canvas.originalSize = size
127 | resetViewport(canvas)
128 |
129 | canvas.requestRenderAll()
130 | }
131 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './imageUtils'
2 | export * from './canvas'
3 | export * from './time'
4 |
5 | import truncate from 'lodash/truncate'
6 |
7 | import { CollaboratorData } from '@airtable/blocks/dist/types/src/types/collaborator'
8 | import is from '@sindresorhus/is'
9 |
10 | export const isDefined = (i: T | void | undefined | null): i is T => {
11 | return !is.nullOrUndefined(i)
12 | }
13 |
14 | export const truncateCollaborator = (
15 | collaborator: CollaboratorData,
16 | length: number
17 | ) => {
18 | return {
19 | ...collaborator,
20 | name: truncate(collaborator.name, { length, separator: ' ' }),
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/time.ts:
--------------------------------------------------------------------------------
1 | import prettyMilliseconds from 'pretty-ms'
2 |
3 | const prettyMillisecondsOptions: prettyMilliseconds.Options = {
4 | compact: true,
5 | secondsDecimalDigits: 0,
6 | }
7 |
8 | export const getTimeFromNow = (ms?: number | null) => {
9 | return ms
10 | ? prettyMilliseconds(Date.now() - ms, prettyMillisecondsOptions) + ' ago'
11 | : null
12 | }
13 |
--------------------------------------------------------------------------------