浏览代码

Merge pull request #8279 from logseq/feat/tweet-shape

feat (whiteboards): add tweet shape
Gabriel Horner 2 年之前
父节点
当前提交
e3b2995a2c

+ 16 - 11
e2e-tests/whiteboards.spec.ts

@@ -3,15 +3,12 @@ import { test } from './fixtures'
 import { IsMac } from './utils'
 
 test('enable whiteboards', async ({ page }) => {
-  await page.evaluate(() => {
-    window.localStorage.setItem('ls-onboarding-whiteboard?', "true")
-  })
-
   await expect(page.locator('.nav-header .whiteboard')).toBeHidden()
   await page.click('#head .toolbar-dots-btn')
   await page.click('#head .dropdown-wrapper >> text=Settings')
   await page.click('.settings-modal a[data-id=features]')
   await page.click('text=Whiteboards >> .. >> .ui__toggle')
+  await page.waitForTimeout(1000)
   await page.keyboard.press('Escape')
   await expect(page.locator('.nav-header .whiteboard')).toBeVisible()
 })
@@ -19,7 +16,6 @@ test('enable whiteboards', async ({ page }) => {
 test('create new whiteboard', async ({ page }) => {
   await page.click('.nav-header .whiteboard')
   await page.click('#tl-create-whiteboard')
-  await page.waitForTimeout(1000)
   await expect(page.locator('.logseq-tldraw')).toBeVisible()
 })
 
@@ -35,19 +31,27 @@ test('can right click title to show context menu', async ({ page }) => {
   await expect(page.locator('#custom-context-menu')).toHaveCount(0)
 })
 
-test('set whiteboard title', async ({ page }) => {
-  const title = 'my-whiteboard'
-  // Newly created whiteboard should have a default title
+test('newly created whiteboard should have a default title', async ({ page }) => {
   await expect(page.locator('.whiteboard-page-title .title')).toContainText(
     'Untitled'
   )
+})
+
+test('set whiteboard title', async ({ page }) => {
+  const title = 'my-whiteboard'
 
+  await page.click('.nav-header .whiteboard')
+  await page.click('#tl-create-whiteboard')
   await page.click('.whiteboard-page-title')
   await page.fill('.whiteboard-page-title input', title)
   await page.keyboard.press('Enter')
   await expect(page.locator('.whiteboard-page-title .title')).toContainText(
     title
   )
+})
+
+test('update whiteboard title', async ({ page }) => {
+  const title = 'my-whiteboard'
 
   await page.click('.whiteboard-page-title')
   await page.fill('.whiteboard-page-title input', title + '-2')
@@ -57,6 +61,7 @@ test('set whiteboard title', async ({ page }) => {
   await expect(page.locator('.ui__confirm-modal >> .headline')).toContainText(
     `Do you really want to change the page name to “${title}-2”?`
   )
+
   await page.click('.ui__confirm-modal button')
   await expect(page.locator('.whiteboard-page-title .title')).toContainText(
     title + '-2'
@@ -95,15 +100,15 @@ test('cleanup the shapes', async ({ page }) => {
 })
 
 test('zoom in', async ({ page }) => {
-  await page.keyboard.press('Shift+0')
-  await page.waitForTimeout(1000)
+  await page.keyboard.press('Shift+0') // reset zoom
+  await page.waitForTimeout(1500) // wait for the zoom animation to finish
   await page.click('#tl-zoom-in')
   await expect(page.locator('#tl-zoom')).toContainText('125%')
 })
 
 test('zoom out', async ({ page }) => {
   await page.keyboard.press('Shift+0')
-  await page.waitForTimeout(1000)
+  await page.waitForTimeout(1500)
   await page.click('#tl-zoom-out')
   await expect(page.locator('#tl-zoom')).toContainText('80%')
 })

+ 7 - 0
src/main/frontend/extensions/tldraw.cljs

@@ -37,6 +37,10 @@
                     {:end-separator? (gobj/get props "endSeparator")
                      :level-limit (gobj/get props "levelLimit" 3)}))
 
+(rum/defc tweet
+  [props]
+  (ui/tweet-embed (gobj/get props "tweetId")))
+
 (rum/defc block-reference
   [props]
   (block/block-reference {} (gobj/get props "blockId") nil))
@@ -71,6 +75,7 @@
 (def tldraw-renderers {:Page page-cp
                        :Block block-cp
                        :Breadcrumb breadcrumb
+                       :Tweet tweet
                        :PageName page-name-link
                        :BacklinksCount references-count
                        :BlockReference block-reference})
@@ -85,6 +90,8 @@
    :isMobile util/mobile?
    :saveAsset save-asset-handler
    :makeAssetUrl editor-handler/make-asset-url
+   :getRedirectPageName (fn [page-name-or-uuid] (model/get-redirect-page-name page-name-or-uuid))
+   :insertFirstPageBlock (fn [page-name] (editor-handler/insert-first-page-block-if-not-exists! page-name {:redirect? false}))
    :addNewWhiteboard (fn [page-name]
                        (whiteboard-handler/create-new-whiteboard-page! page-name))
    :addNewBlock (fn [content]

+ 4 - 4
src/main/frontend/state.cljs

@@ -1762,14 +1762,14 @@ Similar to re-frame subscriptions"
   [args]
   (set-state! :editor/args args))
 
-(defn whiteboard-active-but-not-editing-portal?
+(defn editing-whiteboard-portal?
   []
-  (and (active-tldraw-app) (not (tldraw-editing-logseq-block?))))
+  (and (active-tldraw-app) (tldraw-editing-logseq-block?)))
 
 (defn block-component-editing?
   []
-  (or (:block/component-editing-mode? @state)
-      (whiteboard-active-but-not-editing-portal?)))
+  (and (:block/component-editing-mode? @state)
+       (not (editing-whiteboard-portal?))))
 
 (defn set-block-component-editing-mode!
   [value]

+ 44 - 6
tldraw/apps/tldraw-logseq/src/components/ContextBar/contextBarActionFactory.tsx

@@ -14,6 +14,7 @@ import type {
   Shape,
   TextShape,
   YouTubeShape,
+  TweetShape,
 } from '../../lib'
 import { Button } from '../Button'
 import { TablerIcon } from '../icons'
@@ -39,6 +40,7 @@ export const contextBarActionTypes = [
   'ScaleLevel',
   'TextStyle',
   'YoutubeLink',
+  'TwitterLink',
   'IFrameSource',
   'LogseqPortalViewMode',
   'ArrowMode',
@@ -46,7 +48,7 @@ export const contextBarActionTypes = [
 ] as const
 
 type ContextBarActionType = typeof contextBarActionTypes[number]
-const singleShapeActions: ContextBarActionType[] = ['Edit', 'YoutubeLink', 'IFrameSource', 'Links']
+const singleShapeActions: ContextBarActionType[] = ['Edit', 'YoutubeLink', 'TwitterLink', 'IFrameSource', 'Links']
 
 const contextBarActionMapping = new Map<ContextBarActionType, React.FC>()
 
@@ -62,6 +64,7 @@ export const shapeMapping: Record<ShapeType, ContextBarActionType[]> = {
     'Links',
   ],
   youtube: ['YoutubeLink', 'Links'],
+  tweet: ['TwitterLink', 'Links'],
   iframe: ['IFrameSource', 'Links'],
   box: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
   ellipse: ['Edit', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
@@ -87,7 +90,7 @@ function filterShapeByAction<S extends Shape>(shapes: Shape[], type: ContextBarA
 
 const EditAction = observer(() => {
   const {
-    handlers: { isWhiteboardPage, redirectToPage },
+    handlers: { isWhiteboardPage, redirectToPage, getRedirectPageName, insertFirstPageBlock },
   } = React.useContext(LogseqContext)
 
   const app = useApp<Shape>()
@@ -111,10 +114,17 @@ const EditAction = observer(() => {
               redirectToPage(uuid)
             }
 
-            const firstNonePropertyBlock = window.logseq?.api
-              ?.get_page_blocks_tree?.(shape.props.pageId)
-              .find(b => !('propertiesOrder' in b))
-            uuid = firstNonePropertyBlock.uuid
+            const pageId = getRedirectPageName(shape.props.pageId)
+            let pageBlocksTree = window.logseq?.api?.get_page_blocks_tree?.(pageId)
+
+            if (pageBlocksTree?.length === 0) {
+              insertFirstPageBlock(pageId)
+              pageBlocksTree = window.logseq?.api?.get_page_blocks_tree?.(pageId)
+            }
+
+            const firstNonePropertyBlock = pageBlocksTree?.find(b => !('propertiesOrder' in b)) || pageBlocksTree[0]
+
+            uuid = firstNonePropertyBlock?.uuid
           }
           window.logseq?.api?.edit_block?.(uuid)
         }
@@ -300,6 +310,33 @@ const YoutubeLinkAction = observer(() => {
   )
 })
 
+const TwitterLinkAction = observer(() => {
+  const app = useApp<Shape>()
+  const shape = filterShapeByAction<TweetShape>(app.selectedShapesArray, 'TwitterLink')[0]
+  const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    shape.onTwitterLinkChange(e.target.value)
+    app.persist()
+  }, [])
+
+  return (
+    <span className="flex gap-3">
+      <TextInput
+        title="Twitter Link"
+        className="tl-twitter-link"
+        value={`${shape.props.url}`}
+        onChange={handleChange}
+      />
+      <Button
+        tooltip="Open Twitter Link"
+        type="button"
+        onClick={() => window.logseq?.api?.open_external_link?.(shape.props.url)}
+      >
+        <TablerIcon name="external-link" />
+      </Button>
+    </span>
+  )
+})
+
 const NoFillAction = observer(() => {
   const app = useApp<Shape>()
   const shapes = filterShapeByAction<BoxShape | PolygonShape | EllipseShape>(
@@ -515,6 +552,7 @@ contextBarActionMapping.set('AutoResizing', AutoResizingAction)
 contextBarActionMapping.set('LogseqPortalViewMode', LogseqPortalViewModeAction)
 contextBarActionMapping.set('ScaleLevel', ScaleLevelAction)
 contextBarActionMapping.set('YoutubeLink', YoutubeLinkAction)
+contextBarActionMapping.set('TwitterLink', TwitterLinkAction)
 contextBarActionMapping.set('IFrameSource', IFrameSourceAction)
 contextBarActionMapping.set('NoFill', NoFillAction)
 contextBarActionMapping.set('Swatch', SwatchAction)

+ 14 - 6
tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts

@@ -20,6 +20,9 @@ import {
   LogseqPortalShape,
   VideoShape,
   YouTubeShape,
+  YOUTUBE_REGEX,
+  TweetShape,
+  TWITTER_REGEX,
   type Shape,
 } from '../lib'
 import { LogseqContext, LogseqContextValue } from '../lib/logseq-context'
@@ -269,12 +272,7 @@ const handleCreatingShapes = async (
 
   async function tryCreateShapeFromURL(rawText: string) {
     if (isValidURL(rawText) && !(shiftKey || fromDrop)) {
-      const isYoutubeUrl = (url: string) => {
-        const youtubeRegex =
-          /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
-        return youtubeRegex.test(url)
-      }
-      if (isYoutubeUrl(rawText)) {
+      if (YOUTUBE_REGEX.test(rawText)) {
         return [
           {
             ...YouTubeShape.defaultProps,
@@ -284,6 +282,16 @@ const handleCreatingShapes = async (
         ]
       }
 
+      if (TWITTER_REGEX.test(rawText)) {
+        return [
+          {
+            ...TweetShape.defaultProps,
+            url: rawText,
+            point: [point[0], point[1]],
+          },
+        ]
+      }
+
       return [
         {
           ...IFrameShape.defaultProps,

+ 5 - 0
tldraw/apps/tldraw-logseq/src/lib/logseq-context.ts

@@ -19,6 +19,9 @@ export interface LogseqContextValue {
       levelLimit?: number
       endSeparator?: boolean
     }>
+    Tweet: React.FC<{
+      tweetId: string
+    }>
     PageName: React.FC<{
       pageName: string
     }>
@@ -44,6 +47,8 @@ export interface LogseqContextValue {
     addNewBlock: (content: string) => string // returns the new block uuid
     queryBlockByUUID: (uuid: string) => any
     getBlockPageName: (uuid: string) => string
+    getRedirectPageName: (uuidOrPageName: string) => string
+    insertFirstPageBlock: (pageName: string) => string
     isWhiteboardPage: (pageName: string) => boolean
     isMobile: () => boolean
     saveAsset: (file: File) => Promise<string>

+ 1 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/IFrameShape.tsx

@@ -69,7 +69,7 @@ export class IFrameShape extends TLBoxShape<IFrameShapeProps> {
                 height="100%"
                 src={`${this.props.url}`}
                 frameBorder="0"
-                sandbox="allow-scripts"
+                sandbox="allow-scripts allow-same-origin"
               />
             </div>
           )}

+ 1 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/TextShape.tsx

@@ -131,6 +131,7 @@ export class TextShape extends TLTextShape<TextShapeProps> {
 
     const handleBlur = React.useCallback(
       (e: React.FocusEvent<HTMLTextAreaElement>) => {
+        if (!isEditing) return
         e.currentTarget.setSelectionRange(0, 0)
         onEditingEnd?.()
       },

+ 221 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/TweetShape.tsx

@@ -0,0 +1,221 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { TLBoxShape, TLBoxShapeProps, TLResizeInfo, TLResetBoundsInfo } from '@tldraw/core'
+import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
+import Vec from '@tldraw/vec'
+import { action, computed } from 'mobx'
+import { observer } from 'mobx-react-lite'
+import { withClampedStyles } from './style-props'
+import { LogseqContext } from '../logseq-context'
+import * as React from 'react'
+
+export const TWITTER_REGEX = /https?:\/\/twitter.com\/[0-9a-zA-Z_]{1,20}\/status\/([0-9]*)/
+
+export interface TweetShapeProps extends TLBoxShapeProps {
+  type: 'tweet'
+  url: string
+}
+
+export class TweetShape extends TLBoxShape<TweetShapeProps> {
+  static id = 'tweet'
+
+  static defaultProps: TweetShapeProps = {
+    id: 'tweet',
+    type: 'tweet',
+    parentId: 'page',
+    point: [0, 0],
+    size: [331, 290],
+    url: '',
+  }
+
+  canFlip = false
+  canEdit = true
+  initialHeightCalculated = true
+  getInnerHeight: (() => number) | null = null // will be overridden in the hook
+
+  @computed get embedId() {
+    const url = this.props.url
+    const match = url.match(TWITTER_REGEX)
+    const embedId = match?.[1] ?? url ?? ''
+    return embedId
+  }
+
+  @action onTwitterLinkChange = (url: string) => {
+    this.update({ url, size: TweetShape.defaultProps.size })
+  }
+
+  ReactComponent = observer(({ events, isErasing, isEditing, isSelected }: TLComponentProps) => {
+    const {
+      renderers: { Tweet },
+    } = React.useContext(LogseqContext)
+    const app = useApp<Shape>()
+
+    const cpRefContainer = React.useRef<HTMLDivElement>(null)
+
+    const [, innerHeight] = this.useComponentSize(cpRefContainer)
+
+    React.useEffect(() => {
+      const latestInnerHeight = this.getInnerHeight?.() ?? innerHeight
+      const newHeight = latestInnerHeight
+      if (innerHeight && Math.abs(newHeight - this.props.size[1]) > 1) {
+        this.update({
+          size: [this.props.size[0], newHeight],
+        })
+        app.persist(true)
+      }
+    }, [innerHeight])
+
+    React.useEffect(() => {
+      if (!this.initialHeightCalculated) {
+        setTimeout(() => {
+          this.onResetBounds()
+          app.persist(true)
+        })
+      }
+    }, [this.initialHeightCalculated])
+
+    return (
+      <HTMLContainer
+        style={{
+          overflow: 'hidden',
+          pointerEvents: 'all',
+          opacity: isErasing ? 0.2 : 1,
+        }}
+        {...events}
+      >
+        <div
+          className="rounded-xl w-full h-full relative shadow-xl"
+          style={{
+            pointerEvents: isEditing ? 'all' : 'none',
+            userSelect: 'none',
+          }}
+        >
+          {this.embedId ? (
+              <div ref={cpRefContainer}>
+                <Tweet tweetId={this.embedId}/>
+              </div>
+          ) : (null)}
+        </div>
+      </HTMLContainer>
+    )
+  })
+
+  ReactIndicator = observer(() => {
+    const {
+      props: {
+        size: [w, h],
+      },
+    } = this
+    return <rect width={w} height={h} fill="transparent" rx={8} ry={8} />
+  })
+
+  useComponentSize<T extends HTMLElement>(ref: React.RefObject<T> | null, selector = '') {
+    const [size, setSize] = React.useState<[number, number]>([0, 0])
+    const app = useApp<Shape>()
+    React.useEffect(() => {
+      if (ref?.current) {
+        const el = selector ? ref.current.querySelector<HTMLElement>(selector) : ref.current
+        if (el) {
+          const updateSize = () => {
+            const { width, height } = el.getBoundingClientRect()
+            const bound = Vec.div([width, height], app.viewport.camera.zoom) as [number, number]
+            setSize(bound)
+            return bound
+          }
+          updateSize()
+          this.getInnerHeight = () => updateSize()[1]
+          const resizeObserver = new ResizeObserver(() => {
+            updateSize()
+          })
+          resizeObserver.observe(el)
+          return () => {
+            resizeObserver.disconnect()
+          }
+        }
+      }
+      return () => {}
+    }, [ref, selector])
+    return size
+  }
+
+  getAutoResizeHeight() {
+    if (this.getInnerHeight) {
+      return this.getInnerHeight()
+    }
+    return null
+  }
+
+  onResetBounds = (info?: TLResetBoundsInfo) => {
+    const height = this.getAutoResizeHeight()
+    if (height !== null && Math.abs(height - this.props.size[1]) > 1) {
+      this.update({
+        size: [this.props.size[0], height],
+      })
+      this.initialHeightCalculated = true
+    }
+    return this
+  }
+
+  onResize = (initialProps: any, info: TLResizeInfo): this => {
+    const {
+      bounds,
+      rotation,
+      scale: [scaleX, scaleY],
+    } = info
+    const nextScale = [...this.scale]
+    if (scaleX < 0) nextScale[0] *= -1
+    if (scaleY < 0) nextScale[1] *= -1
+
+    const height = this.getAutoResizeHeight() ?? bounds.height
+
+    return this.update({
+      point: [bounds.minX, bounds.minY],
+      size: [Math.max(1, bounds.width), Math.max(1, height)],
+      scale: nextScale,
+      rotation,
+    })
+  }
+
+  validateProps = (props: Partial<TweetShapeProps>) => {
+    if (props.size !== undefined) {
+      props.size[0] = Math.min(Math.max(props.size[0], 1), 550)
+      props.size[1] = Math.max(props.size[1], 1)
+    }
+    return withClampedStyles(this, props)
+  }
+
+  getShapeSVGJsx() {
+    // Do not need to consider the original point here
+    const bounds = this.getBounds()
+    const embedId = this.embedId
+
+    if (embedId) {
+      return (
+        <g>
+          <rect
+            width={bounds.width}
+            height={bounds.height}
+            fill="#15202b"
+            rx={8}
+            ry={8} />
+          <svg
+            x={bounds.width / 4}
+            y={bounds.height / 4}
+            width={bounds.width / 2}
+            height={bounds.height / 2}
+            viewBox="0 0 15 15"
+            fill="none"
+            xmlns="http://www.w3.org/2000/svg"
+          >
+            <path
+              d="m13.464 4.4401c0.0091 0.13224 0.0091 0.26447 0.0091 0.39793 0 4.0664-3.0957 8.7562-8.7562 8.7562v-0.0024c-1.6721 0.0024-3.3095-0.47658-4.7172-1.3797 0.24314 0.02925 0.48751 0.04387 0.73248 0.04448 1.3857 0.0013 2.7319-0.46374 3.8221-1.3199-1.3169-0.024981-2.4717-0.8836-2.8751-2.1371 0.4613 0.08897 0.93662 0.070688 1.3894-0.053016-1.4357-0.29007-2.4686-1.5515-2.4686-3.0165v-0.039001c0.42779 0.23827 0.90676 0.37051 1.3967 0.38513-1.3522-0.90372-1.769-2.7026-0.95247-4.1091 1.5625 1.9226 3.8678 3.0914 6.3425 3.2151-0.24802-1.0689 0.090798-2.1889 0.89031-2.9403 1.2395-1.1651 3.1889-1.1054 4.3541 0.13346 0.68921-0.13589 1.3498-0.38879 1.9543-0.74711-0.22974 0.71237-0.71054 1.3175-1.3528 1.702 0.60999-0.071907 1.206-0.23522 1.7672-0.48446-0.41316 0.61913-0.93358 1.1584-1.5356 1.5942z"
+              fill="#1d9bf0"
+              fillRule="evenodd"
+              clipRule="evenodd"
+            ></path>
+          </svg>
+        </g>
+      )
+    }
+    return super.getShapeSVGJsx({})
+  }
+}

+ 3 - 3
tldraw/apps/tldraw-logseq/src/lib/shapes/YouTubeShape.tsx

@@ -5,6 +5,8 @@ import { action, computed } from 'mobx'
 import { observer } from 'mobx-react-lite'
 import { withClampedStyles } from './style-props'
 
+export const YOUTUBE_REGEX = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
+
 export interface YouTubeShapeProps extends TLBoxShapeProps {
   type: 'youtube'
   url: string
@@ -32,9 +34,7 @@ export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
 
   @computed get embedId() {
     const url = this.props.url
-    const match = url.match(
-      /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
-    )
+    const match = url.match(YOUTUBE_REGEX)
     const embedId = match?.[1] ?? url ?? ''
     return embedId
   }

+ 4 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/index.ts

@@ -13,6 +13,7 @@ import { PolygonShape } from './PolygonShape'
 import { TextShape } from './TextShape'
 import { VideoShape } from './VideoShape'
 import { YouTubeShape } from './YouTubeShape'
+import { TweetShape } from './TweetShape'
 
 export type Shape =
   // | PenShape
@@ -27,6 +28,7 @@ export type Shape =
   | PolygonShape
   | TextShape
   | YouTubeShape
+  | TweetShape
   | IFrameShape
   | HTMLShape
   | LogseqPortalShape
@@ -46,6 +48,7 @@ export * from './PolygonShape'
 export * from './TextShape'
 export * from './VideoShape'
 export * from './YouTubeShape'
+export * from './TweetShape'
 
 export const shapes: TLReactShapeConstructor<Shape>[] = [
   // DotShape,
@@ -59,6 +62,7 @@ export const shapes: TLReactShapeConstructor<Shape>[] = [
   PolygonShape,
   TextShape,
   YouTubeShape,
+  TweetShape,
   IFrameShape,
   HTMLShape,
   LogseqPortalShape,

+ 8 - 0
tldraw/apps/tldraw-logseq/src/styles.css

@@ -903,12 +903,20 @@ html[data-theme='dark'] {
 }
 
 .tl-youtube-link,
+.tl-twitter-link,
 .tl-iframe-src {
   @apply rounded-lg px-2 py-1;
   color: var(--ls-primary-text-color);
   box-shadow: 0 0 0 1px var(--ls-secondary-border-color);
 }
 
+.logseq-tldraw {
+  .twitter-tweet,
+  iframe {
+    margin: 0 !important;
+  }
+}
+
 .tl-hitarea-stroke {
   fill: none;
   stroke: transparent;