├── .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 | ![Latest version](https://img.shields.io/github/package-json/v/AmrEsyd/annotate) 4 | ![@airtable/blocks](https://img.shields.io/github/package-json/dependency-version/AmrEsyd/annotate/@airtable/blocks) 5 | ![typescript](https://img.shields.io/github/package-json/dependency-version/AmrEsyd/annotate/dev/typescript) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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 | Annotate block preview 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 | 29 | 30 | Settings 31 | 32 | 36 | 39 | 40 | 44 | {annotationsTable && ( 45 | 50 | )} 51 | 52 | 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 | 52 | 53 | 54 | setValue(event.target.value)} 58 | /> 59 | 60 | 61 | 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 | 166 | {options} 167 | 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 | 231 | {modifiedByCollaborator && createdBy !== modifiedBy && ( 232 | 233 | Modified By 234 | 235 | 236 | 242 | 243 | 244 | )} 245 | {createdByCollaborator && ( 246 | 247 | Created By 248 | 249 | 250 | 256 | 257 | 258 | 259 | 260 | Created {getTimeFromNow(createdTime)} 261 | 262 | 263 | 264 | )} 265 | {objectOptions} 266 | 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 | 71 | {options 72 | ? renderOptions(options, styleType, closePopoverOnClick) 73 | : children} 74 | 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 | { 116 | onClick?.(e) 117 | closePopover?.() 118 | }} 119 | {...buttonProps} 120 | > 121 | 122 | {label} 123 | {shortcuts?.[0] && ( 124 | 129 | )} 130 | 131 | 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 | {}}> 51 | Welcome, {session.currentUser?.name} 52 | This block require a separate table to store your annotations. 53 | {permissionsForCreateTable.hasPermission ? ( 54 | 57 | ) : ( 58 | permissionsForCreateTable.reasonDisplayString 59 | )} 60 | 61 | {"If you've used it before you can select the table in "} 62 | showSettings?.()}> 63 | block setting 64 | 65 | 66 | 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 | 219 | 220 | 221 | Keyboard shortcuts 222 | 225 | 226 | {Object.entries(groupBy(shortcutsList, 'group')).map( 227 | ([groupName, groupShortcuts]) => ( 228 | <> 229 | 230 | 231 | 232 | {groupName} 233 | 234 | {groupShortcuts.map(({ label, shortcuts }) => { 235 | return ( 236 | 242 | 248 | 249 | 250 | 251 | {label} 252 | 253 | 254 | ) 255 | })} 256 | 257 | 258 | ) 259 | )} 260 | 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 | --------------------------------------------------------------------------------