From 592e245c59498541f16be31b59913cae9c7e4d78 Mon Sep 17 00:00:00 2001 From: Amelia Wattenberger Date: Tue, 2 Aug 2022 16:17:26 -0700 Subject: [PATCH 1/9] init markdown-edit-block --- blocks.config.json | 10 + blocks/file-blocks/code/theme.tsx | 2 +- .../markdown-edit/block-component-widget.tsx | 486 ++++++++++++++++++ .../file-blocks/markdown-edit/copy-widget.tsx | 115 +++++ .../markdown-edit/highlightActiveLine.ts | 49 ++ .../markdown-edit/image-widget.tsx | 130 +++++ blocks/file-blocks/markdown-edit/index.tsx | 383 ++++++++++++++ blocks/file-blocks/markdown-edit/style.css | 131 +++++ blocks/file-blocks/markdown-edit/theme.tsx | 182 +++++++ package.json | 2 + yarn.lock | 20 + 11 files changed, 1509 insertions(+), 1 deletion(-) create mode 100644 blocks/file-blocks/markdown-edit/block-component-widget.tsx create mode 100644 blocks/file-blocks/markdown-edit/copy-widget.tsx create mode 100644 blocks/file-blocks/markdown-edit/highlightActiveLine.ts create mode 100644 blocks/file-blocks/markdown-edit/image-widget.tsx create mode 100644 blocks/file-blocks/markdown-edit/index.tsx create mode 100644 blocks/file-blocks/markdown-edit/style.css create mode 100644 blocks/file-blocks/markdown-edit/theme.tsx diff --git a/blocks.config.json b/blocks.config.json index bfe6e3a..3ea60dd 100644 --- a/blocks.config.json +++ b/blocks.config.json @@ -117,6 +117,16 @@ "matches": ["*.csv"], "example_path": "https://github.com/the-pudding/data/blob/master/pockets/measurements.csv" }, + { + "type": "file", + "id": "markdown-edit-block", + "title": "Markdown Edit block", + "description": "Edit Markdown content like rich text", + "sandbox": false, + "entry": "blocks/file-blocks/markdown-edit/index.tsx", + "matches": ["*"], + "example_path": "https://github.com/githubnext/blocks-tutorial/blob/main/README.md" + }, { "type": "file", "id": "markdown-block", diff --git a/blocks/file-blocks/code/theme.tsx b/blocks/file-blocks/code/theme.tsx index 66b5445..2d16cf8 100644 --- a/blocks/file-blocks/code/theme.tsx +++ b/blocks/file-blocks/code/theme.tsx @@ -18,7 +18,7 @@ export const oneDarkTheme = EditorView.theme( ".cm-cursor, .cm-dropCursor": { borderLeftColor: colors.cursor }, "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": - { backgroundColor: colors.bg }, + { backgroundColor: colors.selectionBg }, ".cm-panels": { backgroundColor: colors.linesBg, color: colors.text }, ".cm-panels.cm-panels-top": { diff --git a/blocks/file-blocks/markdown-edit/block-component-widget.tsx b/blocks/file-blocks/markdown-edit/block-component-widget.tsx new file mode 100644 index 0000000..9fa670d --- /dev/null +++ b/blocks/file-blocks/markdown-edit/block-component-widget.tsx @@ -0,0 +1,486 @@ +import { syntaxTree } from "@codemirror/language"; +import { Range, RangeSet } from "@codemirror/rangeset"; +import { + EditorState, + Extension, + StateField, + Text, + TransactionSpec, +} from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + WidgetType, +} from "@codemirror/view"; +import { + Block, + BlockPicker, + FileBlockProps, + FolderBlockProps, +} from "@githubnext/blocks"; +import { + Autocomplete, + BaseStyles, + Box, + FormControl, + ThemeProvider, +} from "@primer/react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createRoot } from "react-dom/client"; +import { tw } from "twind"; + +interface BlockParams { + props: Record; + parentProps: FileBlockProps; + onChangeProps: (props: Partial) => void; +} + +class BlockWidget extends WidgetType { + readonly props = {}; + readonly parentProps = null as any; + readonly onChangeProps = (newProps) => {}; + + constructor({ parentProps, props, onChangeProps }: BlockParams) { + super(); + this.parentProps = parentProps; + this.props = props; + this.onChangeProps = onChangeProps; + } + + toDOM() { + const container = document.createElement("div"); + container.classList.add("BlockComponent"); + const parentProps = this.parentProps; + const BlockComponent = parentProps.BlockComponent; + if (!BlockComponent) return container; + createRoot(container).render( + + ); + + return container; + } + + updateDOM(_dom: HTMLElement): boolean { + return false; + } + + eq(_widget: WidgetType): boolean { + return JSON.stringify(this.props) === JSON.stringify(_widget.props); + } + + ignoreEvent(_event: Event): boolean { + return true; + } +} + +export const blockComponentWidget = ({ + parentProps, + onDispatchChanges, +}: { + parentProps: FileBlockProps; + onDispatchChanges: (changes: TransactionSpec) => void; +}): Extension => { + const blockComponentDecoration = (BlockParams: BlockParams) => + Decoration.replace({ + widget: new BlockWidget(BlockParams), + }); + + const decorate = (state: EditorState) => { + const widgets: Range[] = []; + // starts with any number of # + + syntaxTree(state).iterate({ + enter: (type, from, to) => { + let text = state.doc.sliceString(from, to); + if (type.name === "Document") return; + + const locationOfCloseTag = text.indexOf("/>"); + to = from + locationOfCloseTag + 2; + + const blockComponentRegex = /\)/; + const propsString = blockComponentPropsRegex + .exec(text)?.[0] + .split("BlockComponent")[1]; + if (!propsString) return; + let props = {}; + const propsArray = propsString.split("="); + let runningLastPropKey = ""; + propsArray.forEach((prop, index) => { + const lastWordInString = + prop.split(/\s+/)[prop.split(/\s+/).length - 1]; + const key = runningLastPropKey; + runningLastPropKey = lastWordInString; + // slice lastWordInString from end + const valueString = prop.slice( + 0, + prop.length - lastWordInString.length + ); + if (!key || !valueString) return; + + // TODO: extract props from string in a more robust way + try { + eval(`window.parsedValue = ${valueString.trim().slice(1, -1)}`); + props[key] = window.parsedValue; + } catch (e) { + props[key] = valueString; + } + }); + + const onChangeProps = (newProps: any) => { + const newString = ` `${key}={${JSON.stringify(newProps[key])}}`) + .join("\n")} +/>`; + + onDispatchChanges({ + changes: { + from, + to, + insert: Text.of([newString]), + }, + }); + }; + const newDecoration = blockComponentDecoration({ + parentProps, + props, + onChangeProps, + }); + widgets.push(newDecoration.range(from, to)); + } + }, + }); + + if (!widgets.length) return Decoration.none; + + return RangeSet.of(widgets); + }; + + const theme = EditorView.baseTheme({}); + + const field = StateField.define({ + create(state) { + return decorate(state); + }, + update(copys, transaction) { + if (transaction.docChanged) { + return decorate(transaction.state); + } + + return copys.map(transaction.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + }, + }); + + return [theme, field]; +}; + +const BlockComponentWrapper = ({ + parentProps, + props, + onChangeProps, +}: { + parentProps: FileBlockProps; + props: Partial; + onChangeProps: (newProps: Partial) => void; +}) => { + const BlockComponent = parentProps.BlockComponent; + + return ( + // @ts-ignore + + +
+
+ + + + +
+
+
+
+ ); +}; + +type FullProps = (FileBlockProps | FolderBlockProps) & { + block: Block; +}; + +const useOnRequestData = ( + fetch: () => Promise +): { + data: any; + isLoading: boolean; + error: any; +} => { + const [data, setData] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const fetchData = async () => { + setIsLoading(true); + try { + const res = await fetch(); + setIsLoading(false); + setData(res); + setError(null); + } catch (e) { + setIsLoading(false); + setError(e); + } + }; + useEffect(() => { + fetchData(); + }, [fetch]); + + return { data, error, isLoading }; +}; + +const ContextControls = ({ + props, + parentProps, + onChangeProps, +}: { + props: Partial; + parentProps: FileBlockProps; + onChangeProps: (newProps: Partial) => void; +}) => { + const isSameRepoAsParent = + `${parentProps.context?.owner}/${parentProps.context?.repo}` === + `${props.context?.owner}/${props.context?.repo}`; + const combinedContext = { + ...(parentProps.context || {}), + ...(props.context || {}), + sha: isSameRepoAsParent ? parentProps.context.sha : "HEAD", + }; + const blocksRepo = `${(props.block || {}).owner}/${(props.block || {}).repo}`; + + const currentSearchTermRepos = useRef(""); + const contentRepo = `${combinedContext.owner}/${combinedContext.repo}`; + const onFetchRepos = useCallback( + async (searchTerm: string) => { + currentSearchTermRepos.current = searchTerm; + const repos = await parentProps.onRequestGitHubData( + "/search/repositories", + { + sort: "stars", + direction: "desc", + per_page: 10, + q: searchTerm || "blocks", + } + ); + if (currentSearchTermRepos.current !== searchTerm) return; + const repoNames = repos.items.map((repo) => ({ + text: repo.full_name, + id: repo.full_name, + value: { + owner: repo.owner.login, + repo: repo.name, + path: "", + }, + })); + return repoNames; + }, + [parentProps.onRequestBlocksRepos, blocksRepo] + ); + + const currentSearchTermPaths = useRef(""); + const onFetchRepoPaths = useCallback( + async (searchTerm: string) => { + currentSearchTermPaths.current = searchTerm; + const paths = await parentProps.onRequestGitHubData( + `/repos/${contentRepo}/contents`, + { + per_page: 100, + } + ); + if (currentSearchTermPaths.current !== searchTerm) return; + const repoPaths = paths + .filter( + (path) => + !props.block?.type || + (props.block?.type === "file" && path.type === "file") || + (props.block?.type === "folder" && path.type === "dir") + ) + .map((path) => ({ + text: path.name, + id: path.name, + value: { path: path.name }, + })); + const rootPath = + props.block?.type === "folder" + ? { text: "/", id: "/", value: { path: "" } } + : null; + return [rootPath, ...repoPaths].filter((path) => { + if (!path) return; + const doesMatchValue = searchTerm === props.context?.path; + if (doesMatchValue || !searchTerm) return true; + return path.id?.toLowerCase().includes(searchTerm.toLowerCase()); + }); + }, + [parentProps.onRequestBlocksRepos, blocksRepo] + ); + + return ( + + + + Block + { + onChangeProps({ ...props, block }); + }} + onRequestBlocksRepos={parentProps.onRequestBlocksRepos} + > +
+ {props.block?.title || props.block?.id} +
+
+
+ { + onChangeProps({ + ...props, + context: { ...combinedContext, ...newValue }, + }); + }} + /> + { + onChangeProps({ + ...props, + context: { ...combinedContext, ...newValue }, + }); + }} + /> +
+
+ ); +}; + +let runningI = 0; +const getUniqueId = (prefix = "") => { + runningI++; + return prefix + runningI; +}; + +const Input = ({ + value, + placeholder, + label, + onChange, + itemSearchFunction, +}: { + value: string; + label: string; + placeholder?: string; + onChange: (value: Record) => void; + itemSearchFunction: (searchTerm: string) => Promise; +}) => { + const [search, setSearch] = useState(value); + const id = useMemo(() => getUniqueId("Input--"), []); + const anchorElement = useRef(null); + + useEffect(() => { + setSearch(value); + }, [value]); + + const requestDataFunction = useCallback( + async () => await itemSearchFunction(search), + [itemSearchFunction, search] + ); + const { data: items, isLoading } = useOnRequestData(requestDataFunction); + + return ( + + {label} + + { + e.stopPropagation(); + e.preventDefault(); + setSearch(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.target.blur(); + // need to prevent clearing the input + setTimeout(() => { + // this doesn't work, for some reason + // setSearch(value) + e.target.value = value; + }); + } + }} + /> +
+ { + const ids = selectedItemIds.filter(Boolean); + const id = ids.slice(-1)[0]; + onChange(id ? id.value : undefined); + }} + filterFn={() => true} + /> +
+
+
+ ); +}; diff --git a/blocks/file-blocks/markdown-edit/copy-widget.tsx b/blocks/file-blocks/markdown-edit/copy-widget.tsx new file mode 100644 index 0000000..730e7b7 --- /dev/null +++ b/blocks/file-blocks/markdown-edit/copy-widget.tsx @@ -0,0 +1,115 @@ +import { syntaxTree } from "@codemirror/language"; +import { Range, RangeSet } from "@codemirror/rangeset"; +import { EditorState, Extension, StateField } from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + WidgetType, +} from "@codemirror/view"; + +class HorizontalRuleWidget extends WidgetType { + toDOM() { + const hr = document.createElement("hr"); + return hr; + } +} + +export const copy = (): Extension => { + const headerDecoration = ({ level }: { level: string }) => + Decoration.mark({ + class: `cm-copy-header cm-copy-header--${level}`, + }); + const linkDecoration = (text: string, linkText: string, url: string) => + Decoration.mark({ + class: text.includes(url) ? "cm-copy-link" : "", + tagName: "a", + attributes: { + href: url, + target: "_top", + title: linkText, + onclick: `top.window.location.href='${url}'`, + }, + }); + const horizontalRuleDecorationAfter = () => + Decoration.mark({ + class: "cm-copy-hr", + }); + + const decorate = (state: EditorState) => { + const widgets: Range[] = []; + // starts with any number of # + + const tree = syntaxTree(state); + tree.iterate({ + enter: (type, from, to) => { + if (type.name.startsWith("ATXHeading")) { + const level = type.name.split("Heading")[1]; + const newDecoration = headerDecoration({ level }); + widgets.push( + newDecoration.range( + state.doc.lineAt(from).from, + state.doc.lineAt(to).to + ) + ); + } else if (type.name === "SetextHeading2") { + const newDecoration = horizontalRuleDecorationAfter(); + // const toPos = state.doc.lineAt(to).to + widgets.push(newDecoration.range(from, to)); + } else if (type.name === "Link") { + const text = state.doc.sliceString(from, to); + const linkRegex = /\[.*?\]\((?.*?)\)/; + const url = linkRegex.exec(text)?.groups?.url; + const linkTextRegex = /\[(?.*?)\]/; + const linkText = linkTextRegex.exec(text)?.groups?.text; + if (url) { + const newDecoration = linkDecoration(text, linkText, url); + widgets.push(newDecoration.range(from, to)); + } + } else if (type.name === "HTMLTag") { + // punting for now, it splits the text into multiple widgets: + // start tag, text, end tag + // const linkRegexHtml = /.*?)".*?>(?.*?)[<\/a>]*/ + // const result = linkRegexHtml.exec(state.doc.sliceString(from, to)) + // if (result && result.groups && result.groups.url) { + // let linkText = result.groups.text + // if (!linkText && !linkText.includes("")) { + // const nextNode = tree.resolve(to) + // linkText = state.doc.slice(nextNode.from, nextNode.to).text[0] + // to += linkText.length + // } + // const url = result.groups.url + // const newDecoration = linkDecoration(linkText, url) + // widgets.push(newDecoration.range(from, to)) + // } + } else if (type.name === "HorizontalRule") { + const newDecoration = horizontalRuleDecorationAfter(); + widgets.push(newDecoration.range(from, to)); + } + }, + }); + + return widgets.length > 0 ? RangeSet.of(widgets) : Decoration.none; + }; + + const copysTheme = EditorView.baseTheme({}); + + const copysField = StateField.define({ + create(state) { + return decorate(state); + }, + update(copys, transaction) { + console.log(transaction); + if (transaction.docChanged) { + return decorate(transaction.state); + } + + return copys.map(transaction.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + }, + }); + + return [copysTheme, copysField]; +}; diff --git a/blocks/file-blocks/markdown-edit/highlightActiveLine.ts b/blocks/file-blocks/markdown-edit/highlightActiveLine.ts new file mode 100644 index 0000000..321b5dd --- /dev/null +++ b/blocks/file-blocks/markdown-edit/highlightActiveLine.ts @@ -0,0 +1,49 @@ +import { Extension } from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, +} from "@codemirror/view"; + +/// Mark lines that have a cursor on them with the `"cm-activeLine"` +/// DOM class. +export function highlightActiveLine(): Extension { + return activeLineHighlighter; +} + +const lineDeco = Decoration.line({ class: "cm-activeLine" }); + +const activeLineHighlighter = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.getDeco(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet) + this.decorations = this.getDeco(update.view); + } + + getDeco(view: EditorView) { + let lastLineStart = -1, + deco = []; + for (let r of view.state.selection.ranges) { + // if (!r.empty) return Decoration.none; + console.log(view.state.selection.ranges); + let line = view.lineBlockAt(r.head); + if (line.from > lastLineStart) { + deco.push(lineDeco.range(line.from)); + lastLineStart = line.from; + } + } + return Decoration.set(deco); + } + }, + { + decorations: (v) => v.decorations, + } +); diff --git a/blocks/file-blocks/markdown-edit/image-widget.tsx b/blocks/file-blocks/markdown-edit/image-widget.tsx new file mode 100644 index 0000000..df4e83f --- /dev/null +++ b/blocks/file-blocks/markdown-edit/image-widget.tsx @@ -0,0 +1,130 @@ +import { syntaxTree } from "@codemirror/language"; +import { Range, RangeSet } from "@codemirror/rangeset"; +import { EditorState, Extension, StateField } from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + WidgetType, +} from "@codemirror/view"; + +interface ImageWidgetParams { + url: string; +} + +class ImageWidget extends WidgetType { + readonly url; + + constructor({ url }: ImageWidgetParams) { + super(); + + this.url = url; + } + + eq(imageWidget: ImageWidget) { + return imageWidget.url === this.url; + } + + toDOM() { + const container = document.createElement("div"); + const backdrop = container.appendChild(document.createElement("div")); + const figure = backdrop.appendChild(document.createElement("figure")); + const image = figure.appendChild(document.createElement("img")); + + container.setAttribute("aria-hidden", "true"); + container.className = "cm-image-container"; + backdrop.className = "cm-image-backdrop"; + figure.className = "cm-image-figure"; + image.className = "cm-image-img"; + image.src = this.url; + + container.style.paddingBottom = "0.5rem"; + container.style.paddingTop = "0.5rem"; + + backdrop.classList.add("cm-image-backdrop"); + + backdrop.style.borderRadius = "var(--ink-internal-all-border-radius)"; + backdrop.style.display = "flex"; + // backdrop.style.alignItems = 'center' + // backdrop.style.justifyContent = 'center' + backdrop.style.overflow = "hidden"; + backdrop.style.maxWidth = "100%"; + + figure.style.margin = "0"; + + image.style.display = "block"; + image.style.maxHeight = "var(--ink-internal-block-max-height)"; + image.style.maxWidth = "100%"; + image.style.width = "100%"; + + return container; + } +} + +export const images = (): Extension => { + const imageRegex = /!\[.*?\]\((?.*?)\)/; + const imageRegexHtml = /.*?)".*?>/; + + const imageDecoration = (imageWidgetParams: ImageWidgetParams) => + Decoration.widget({ + widget: new ImageWidget(imageWidgetParams), + side: 1, + block: true, + }); + + const decorate = (state: EditorState) => { + const widgets: Range[] = []; + + syntaxTree(state).iterate({ + enter: (type, from, to) => { + if (type.name === "Image") { + const result = imageRegex.exec(state.doc.sliceString(from, to)); + + if (result && result.groups && result.groups.url) { + widgets.push( + imageDecoration({ url: result.groups.url }).range( + state.doc.lineAt(from).from + ) + ); + } + } else if (type.name === "HTMLBlock") { + const result = imageRegexHtml.exec(state.doc.sliceString(from, to)); + + if (result && result.groups && result.groups.url) { + widgets.push( + imageDecoration({ url: result.groups.url }).range( + state.doc.lineAt(from).from + ) + ); + } + } + }, + }); + + return widgets.length > 0 ? RangeSet.of(widgets) : Decoration.none; + }; + + const imagesTheme = EditorView.baseTheme({ + ".cm-image-backdrop": { + backgroundColor: "var(--ink-internal-block-background-color)", + }, + }); + + const imagesField = StateField.define({ + create(state) { + return decorate(state); + }, + update(images, transaction) { + if (transaction.docChanged) { + return decorate(transaction.state); + } + + return images.map(transaction.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + }, + }); + + return [imagesTheme, imagesField]; +}; diff --git a/blocks/file-blocks/markdown-edit/index.tsx b/blocks/file-blocks/markdown-edit/index.tsx new file mode 100644 index 0000000..1563aed --- /dev/null +++ b/blocks/file-blocks/markdown-edit/index.tsx @@ -0,0 +1,383 @@ +import { FileBlockProps } from "@githubnext/utils"; +import { ActionList, ActionMenu, Text } from "@primer/react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { tw } from "twind"; +import "./style.css"; + +import { autocompletion, completionKeymap } from "@codemirror/autocomplete"; +import { closeBrackets, closeBracketsKeymap } from "@codemirror/closebrackets"; +import { defaultKeymap } from "@codemirror/commands"; +import { commentKeymap } from "@codemirror/comment"; +import { foldGutter, foldKeymap } from "@codemirror/fold"; +import { highlightActiveLineGutter } from "@codemirror/gutter"; +import { defaultHighlightStyle } from "@codemirror/highlight"; +import { history, historyKeymap } from "@codemirror/history"; +import { markdown } from "@codemirror/lang-markdown"; +import { indentOnInput } from "@codemirror/language"; +import { lintKeymap } from "@codemirror/lint"; +import { bracketMatching } from "@codemirror/matchbrackets"; +import { rectangularSelection } from "@codemirror/rectangular-selection"; +import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; +import { Compartment, EditorState } from "@codemirror/state"; +import { + drawSelection, + dropCursor, + EditorView, + highlightSpecialChars, + keymap, + ViewUpdate, +} from "@codemirror/view"; +import { Block } from "@githubnext/blocks"; +import interact from "@replit/codemirror-interact"; +import { copy } from "./copy-widget"; +import { blockComponentWidget } from "./block-component-widget"; +import { images } from "./image-widget"; +import { highlightActiveLine } from "./highlightActiveLine"; +import { theme } from "./theme"; +const languageConf = new Compartment(); + +const extensions = [ + // lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + defaultHighlightStyle.fallback, + bracketMatching(), + closeBrackets(), + autocompletion(), + rectangularSelection(), + highlightActiveLine(), + highlightSelectionMatches(), + + markdown(), + images(), + copy(), + theme, + + interact({ + rules: [ + // dragging numbers + { + regexp: /-?\b\d+\.?\d*\b/g, + cursor: "ew-resize", + onDrag: (text, setText, e) => { + const newVal = Number(text) + e.movementX; + if (isNaN(newVal)) return; + setText(newVal.toString()); + }, + }, + ], + }), +]; + +export default function (props: FileBlockProps) { + const { + content, + context: { path }, + isEditable, + onUpdateContent, + onRequestBlocksRepos, + BlockComponent, + } = props; + + const editorRef = React.useRef(null); + const viewRef = React.useRef(); + const [searchTerm, setSearchTerm] = useState(""); + const currentSearchTerm = useRef(searchTerm); + useEffect(() => { + currentSearchTerm.current = searchTerm; + }, [searchTerm]); + const [autocompleteLocation, setAutocompleteLocation] = + useState(null); + const [autocompleteFocusedBlockIndex, setAutocompleteFocusedBlockIndex] = + useState(0); + const isAutocompleting = useRef(false); + const autocompleteFocusedBlock = useRef(null); + + const [blocks, setBlocks] = useState([]); + const [isLoadingBlocks, setIsLoadingBlocks] = useState(false); + + // useEffect(() => { + // if (typeof window === "undefined") return; + // window.BlockComponent = BlockComponent + // }, []) + useEffect(() => { + isAutocompleting.current = !!autocompleteLocation; + }, [autocompleteLocation]); + useEffect(() => { + autocompleteFocusedBlock.current = blocks[autocompleteFocusedBlockIndex]; + }, [blocks, autocompleteFocusedBlockIndex]); + + const onDiffAutocompleteFocusedBlockIndex = useRef((diff: number) => {}); + useEffect(() => { + onDiffAutocompleteFocusedBlockIndex.current = (diff: number) => { + setAutocompleteFocusedBlockIndex( + (i) => (i + diff + blocks.length) % blocks.length + ); + }; + }, [blocks]); + const onSelectAutocompleteFocusedBlock = useRef((diff: number) => {}); + useEffect(() => { + onSelectAutocompleteFocusedBlock.current = () => { + const view = viewRef.current; + if (!view) return false; + const { doc } = view.state; + const { from, to } = view.state.selection.ranges[0]; + const previousText = doc.slice(0, from).toString(); + const activeLineText = previousText.split("\n").slice(-1)[0]; + const block = autocompleteFocusedBlock.current; + if (!block) return false; + const newText = ``; + view.dispatch({ + changes: { + from: from - activeLineText.length, + to, + insert: newText, + }, + }); + setAutocompleteLocation(null); + setAutocompleteFocusedBlockIndex(0); + setSearchTerm(""); + }; + }, [blocks]); + + const updateBlocks = async () => { + setBlocks([]); + setIsLoadingBlocks(true); + const res = await onRequestBlocksRepos({ + searchTerm, + }); + if (currentSearchTerm.current !== searchTerm) return; + const blocks = res.reduce((acc, repo) => { + return [...acc, ...repo.blocks]; + }, []); + + setBlocks(blocks); + setIsLoadingBlocks(false); + }; + + useEffect(() => { + updateBlocks(); + }, [searchTerm]); + + if (viewRef.current) { + const view = viewRef.current; + const doc = view.state.doc.sliceString(0); + if (doc !== content) { + view.dispatch({ + changes: { from: 0, to: doc.length, insert: content }, + }); + } + } + + React.useEffect(() => { + if (viewRef.current || !editorRef.current) return; + const onDispatchChanges = (changes: any) => { + console.log(changes); + if (viewRef.current) viewRef.current.dispatch(changes); + }; + const state = EditorState.create({ + doc: content, + extensions: [ + extensions, + blockComponentWidget({ parentProps: props, onDispatchChanges }), + keymap.of([ + // prevent default behavior for arrow keys when autocompleting + { + key: "ArrowDown", + run: () => { + if (!isAutocompleting.current) return false; + onDiffAutocompleteFocusedBlockIndex.current(1); + return true; + }, + }, + { + key: "ArrowUp", + run: () => { + if (!isAutocompleting.current) return false; + onDiffAutocompleteFocusedBlockIndex.current(-1); + return true; + }, + }, + { + key: "Enter", + run: () => { + if (!isAutocompleting.current) return false; + onSelectAutocompleteFocusedBlock.current(); + return true; + }, + }, + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...commentKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + EditorView.editable.of(isEditable), + EditorView.updateListener.of((v) => { + if (!v.docChanged) return; + onUpdateContent(v.state.doc.sliceString(0)); + window.state = v.state; + }), + + EditorView.updateListener.of((v: ViewUpdate) => { + if (v.docChanged || v.selectionChanged) { + const cursorPosition = v.state.selection.ranges[0].to; + const text = v.state.doc.sliceString(0, cursorPosition); + const activeLine = text.split("\n").slice(-1)[0]; + const startOfLinePosition = cursorPosition - activeLine.length; + const isAutocompleting = + activeLine.startsWith("/") && !activeLine.includes("/>"); + if (!isAutocompleting) { + setSearchTerm(""); + setAutocompleteLocation(null); + return; + } + const cursorLocation = v.view.coordsAtPos(startOfLinePosition); + const scrollOffset = -v.view.contentDOM.getBoundingClientRect().top; + cursorLocation["top"] += scrollOffset; + setAutocompleteLocation(cursorLocation); + setSearchTerm(activeLine.slice(1)); + // } else if (v.transactions[0]) { + } + }), + ], + }); + const view = new EditorView({ + state, + parent: editorRef.current, + }); + + viewRef.current = view; + }, []); + + return ( +
+ {!!autocompleteLocation && ( +
+ { + setAutocompleteLocation(null); + setSearchTerm(""); + }} + /> +
+ )} +
+
+ ); +} + +const WidgetPicker = ({ + location, + isLoading, + blocks, + focusedBlockIndex, + onClose, +}: { + location?: DOMRect; + isLoading: boolean; + blocks: Block[]; + focusedBlockIndex: number; + onClose: () => void; +}) => { + // useEffect(() => { + // const onKeyDown = (e: KeyboardEvent) => { + // if (e.key === "ArrowDown") { + // e.preventDefault(); + // e.stopPropagation() + // // firstItemRef.current && firstItemRef.current.focus() + // setFocusedItemIndex(i => i + 1) + // } else if (e.key === "ArrowUp") { + // e.preventDefault(); + // e.stopPropagation() + // setFocusedItemIndex(i => i - 1) + // // const isLastItemFocused = lastItemRef.current && lastItemRef.current === document.activeElement + // // if (isLastItemFocused) { + // // onFocusEditor() + // // } + // } + // } + // window.addEventListener("keydown", onKeyDown) + + // return () => { + // window.removeEventListener("keydown", onKeyDown) + // } + // }, [blocks]) + + return ( +
+ + + Open Actions Menu + + + {isLoading ? ( +
+ Loading... +
+ ) : !blocks.length ? ( +
+ No Blocks found +
+ ) : ( + + {blocks.map((block, index) => ( +
+
{block.title}
+
+ {block.owner}/{block.repo} +
+ {/* ⌘O */} +
+ ))} +
+ )} +
+
+
+ ); +}; diff --git a/blocks/file-blocks/markdown-edit/style.css b/blocks/file-blocks/markdown-edit/style.css new file mode 100644 index 0000000..afbc028 --- /dev/null +++ b/blocks/file-blocks/markdown-edit/style.css @@ -0,0 +1,131 @@ +@import url("https://rsms.me/inter/inter.css"); +html { + font-family: "Inter", sans-serif; +} +@supports (font-variation-settings: normal) { + html { + font-family: "Inter var", sans-serif; + } +} + +html, +body, +#root { + height: 100%; +} + +.cm-editor { + padding: 2em 1em 10em; + max-width: 60em; + min-height: 100%; + margin: 0 auto; + outline: none !important; +} + +.cm-scroller { +} + +.cm-editor div.cm-scroller { + font-size: 1.1em; + font-family: "Inter var", sans-serif; + font-feature-settings: "tnum" 1; +} + +.cm-line { + white-space: pre-wrap; +} +.cm-line .cm { + opacity: 0.5; +} + +.cm-copy-header { + font-size: 1.2em; + font-weight: 900; + padding: 1em 0 0; + display: inline-block; +} +.cm-copy-header--1 { + font-size: 2em; +} +.cm-copy-header--2 { + font-size: 1.7em; +} +.cm-copy-header--3 { + font-size: 1.4em; +} + +.cm-copy-link { + display: inline-block; + cursor: pointer; + pointer-events: all; +} + +.cm-copy-hr:after { + content: ""; + position: absolute; + left: 0; + right: 0; + border-top: 1px solid rgb(225, 228, 232); + transform: translate(0, 0.7em); +} + +.cm-autocomplete-wrapper { + position: absolute; + z-index: 1; + background-color: #fff; + border: 1px solid #ccc; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + border-radius: 3px; + min-width: 10em; + min-height: 2em; + padding: 0.3em 0.6em; + transform: translate(0, 0.2em); +} + +.cm-line .BlockComponentWrapper input { + caret-color: #000; +} +.cm-editor .cm-line::selection, +.cm-editor .cm-activeLine::selection, +.cm-editor .ͼy::selection { + background: rgba(84, 174, 255, 0.4) !important; +} +.cm-line .BlockComponentWrapper input::selection { + background: rgba(84, 174, 255, 0.4) !important; +} + +.cm-copy-header .ͼ15 { + display: inline-block; + width: 0; + opacity: 0; + margin-right: -0.3em; +} +.cm-copy-link span { + display: none; +} +.cm-copy-link:after { + content: attr(title); + color: #0550ae; +} + +.cm-activeLine .ͼ15, +.cm-activeLine .ͼu, +.cm-activeLine .cm-copy-link span { + display: inline-block; + width: auto; + opacity: 1; + margin-right: 0; +} +.cm-activeLine .cm-copy-link:after { + display: none; +} + +.cm-copy-hr { + color: transparent; +} +.cm-activeLine .cm-copy-hr { + color: inherit; +} +.cm-activeLine .cm-copy-hr:after { + display: none; +} diff --git a/blocks/file-blocks/markdown-edit/theme.tsx b/blocks/file-blocks/markdown-edit/theme.tsx new file mode 100644 index 0000000..b1d64fd --- /dev/null +++ b/blocks/file-blocks/markdown-edit/theme.tsx @@ -0,0 +1,182 @@ +import { EditorView } from "@codemirror/view"; +import { Extension } from "@codemirror/state"; +import { HighlightStyle } from "@codemirror/highlight"; +import { tags as t } from "@lezer/highlight"; +import { parser } from "@lezer/markdown"; +console.log(parser); +import primer from "@primer/primitives"; + +const colors = primer.colors.light.codemirror; + +export const colorTheme = EditorView.theme( + { + "&": { + color: colors.text, + backgroundColor: colors.bg, + }, + + ".cm-content": { + caretColor: colors.cursor, + }, + + ".cm-cursor, .cm-dropCursor": { borderLeftColor: colors.cursor }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { backgroundColor: colors.selectionBg }, + + ".cm-panels": { backgroundColor: colors.linesBg, color: colors.text }, + ".cm-panels.cm-panels-top": { + borderBottom: `2px solid ${colors.guttersBg}`, + }, + ".cm-panels.cm-panels-bottom": { + borderTop: `2px solid ${colors.guttersBg}`, + }, + + ".cm-searchMatch": { + backgroundColor: colors.selectionBg, + outline: `1px solid ${colors.selectionBg}`, + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: "#6199ff2f", + }, + + ".cm-activeLine": { backgroundColor: colors.bg }, + + ".cm-selectionMatch": { backgroundColor: colors.selectionBg }, + + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + backgroundColor: colors.selectionBg, + outline: `1px solid ${colors.selectionBg}`, + }, + + ".cm-gutters": { + backgroundColor: colors.guttersBg, + color: colors.guttermarkerSubtleText, + border: "none", + }, + + ".cm-activeLineGutter": { + backgroundColor: colors.bg, + }, + + ".cm-foldPlaceholder": { + backgroundColor: "transparent", + border: "none", + color: colors.guttermarkerText, + }, + + ".cm-tooltip": { + border: "none", + backgroundColor: colors.bg, + }, + ".cm-tooltip .cm-tooltip-arrow:before": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + ".cm-tooltip .cm-tooltip-arrow:after": { + borderTopColor: colors.bg, + borderBottomColor: colors.bg, + }, + ".cm-tooltip-autocomplete": { + "& > ul > li[aria-selected]": { + backgroundColor: colors.selectionBg, + color: colors.text, + }, + }, + }, + { dark: true } +); + +export const highlightStyle = HighlightStyle.define([ + { + tag: t.keyword, + color: colors.syntax.keyword, + }, + { + tag: [t.deleted, t.character, t.propertyName, t.macroName], + color: colors.syntax.constant, + }, + { + tag: [t.function(t.variableName), t.labelName], + color: colors.syntax.entity, + }, + { + tag: [t.color, t.constant(t.name), t.standard(t.name)], + color: colors.syntax.string, + }, + { + tag: [ + t.operator, + t.operatorKeyword, + t.url, + t.escape, + t.regexp, + t.link, + t.special(t.string), + ], + color: colors.syntax.string, + }, + { + tag: [t.meta, t.comment], + color: colors.syntax.comment, + }, + { + tag: t.strong, + fontWeight: "bold", + }, + { + tag: t.monospace, + fontFamily: "monospace", + fontSize: "1.1em", + backgroundColor: colors.activelineBg, + }, + { + tag: t.quote, + // paddingLeft: "2em", + }, + { + tag: t.emphasis, + fontStyle: "italic", + }, + { + tag: t.strikethrough, + textDecoration: "line-through", + }, + { + tag: t.link, + color: colors.syntax.constant, + textDecoration: "underline", + }, + { + tag: t.heading, + fontWeight: "bold", + // color: colors.syntax.entity + color: primer.colors.light.header.bg, + }, + { + tag: [t.atom, t.bool, t.special(t.variableName)], + color: colors.syntax.keyword, + }, + { + tag: [t.string], + color: colors.syntax.string, + }, + { + tag: [t.processingInstruction, t.inserted], + color: primer.colors.light.primer.canvas.backdrop, + opacity: 0.5, + }, + { + tag: [t.special(t.variableName), t.special(t.propertyName)], + color: colors.syntax.keyword, + }, + { + tag: t.angleBracket, + color: colors.syntax.constant, + }, + { + tag: t.invalid, + color: colors.syntax.support, + }, +]); + +export const theme: Extension = [colorTheme, highlightStyle]; diff --git a/package.json b/package.json index 9e4a8e7..51a56b0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "@fullstackio/remark-cq": "^6.1.2", "@githubnext/blocks": "^2.0.2", "@githubocto/flat-ui": "^0.14.1", + "@lezer/highlight": "^1.0.0", + "@lezer/markdown": "^1.0.1", "@mdx-js/runtime": "2.0.0-next.9", "@octokit/rest": "^18.12.0", "@primer/octicons-react": "^16.0.0", diff --git a/yarn.lock b/yarn.lock index cd7984a..8d597b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -905,6 +905,11 @@ resolved "https://registry.yarnpkg.com/@lezer/common/-/common-0.15.12.tgz#2f21aec551dd5fd7d24eb069f90f54d5bc6ee5e9" integrity sha512-edfwCxNLnzq5pBA/yaIhwJ3U3Kz8VAUOTRg0hhxaizaI1N+qxV7EXDv/kLCkLeq2RzSFvxexlaj5Mzfn2kY0Ig== +"@lezer/common@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.0.0.tgz#1c95ae53ec17706aa3cbcc88b52c23f22ed56096" + integrity sha512-ohydQe+Hb+w4oMDvXzs8uuJd2NoA3D8YDcLiuDsLqH+yflDTPEpgCsWI3/6rH5C3BAedtH1/R51dxENldQceEA== + "@lezer/cpp@^0.15.0": version "0.15.3" resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-0.15.3.tgz#51499ec09da0eef9f6d7fa3f6497c57c46162c3e" @@ -919,6 +924,13 @@ dependencies: "@lezer/lr" "^0.15.0" +"@lezer/highlight@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.0.0.tgz#1dc82300f5d39fbd67ae1194b5519b4c381878d3" + integrity sha512-nsCnNtim90UKsB5YxoX65v3GEIw3iCHw9RM2DtdgkiqAbKh9pCdvi8AWNwkYf10Lu6fxNhXPpkpHbW6mihhvJA== + dependencies: + "@lezer/common" "^1.0.0" + "@lezer/html@^0.15.0": version "0.15.1" resolved "https://registry.yarnpkg.com/@lezer/html/-/html-0.15.1.tgz#973a5a179560d0789bf8737c06e6d143cc211406" @@ -961,6 +973,14 @@ dependencies: "@lezer/common" "^0.15.0" +"@lezer/markdown@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.0.1.tgz#ea4f50dc4b94a54d2cdae27c34d5cebb6b723f33" + integrity sha512-LlpNWLqes3XQvd8TwpJTHf9ENl4fI6H32xQkMgltUITFMMdQpOASXQtDawWR03yS6hskh4bkhATQbgjdGMoUvA== + dependencies: + "@lezer/common" "^1.0.0" + "@lezer/highlight" "^1.0.0" + "@lezer/php@^0.15.0": version "0.15.0" resolved "https://registry.yarnpkg.com/@lezer/php/-/php-0.15.0.tgz#d09abd0ffaf256dcfac9b78cf4e6f2ee930b9efa" From 07e590f627423a249876e51881e25a73625e7658 Mon Sep 17 00:00:00 2001 From: Amelia Wattenberger Date: Fri, 5 Aug 2022 11:26:16 -0700 Subject: [PATCH 2/9] markdown block - update styles --- .../markdown-edit/block-component-widget.tsx | 9 +- .../file-blocks/markdown-edit/copy-widget.tsx | 181 ++++++++++++++++-- .../markdown-edit/highlightActiveLine.ts | 2 +- .../markdown-edit/image-widget.tsx | 40 +++- blocks/file-blocks/markdown-edit/index.tsx | 8 +- blocks/file-blocks/markdown-edit/style.css | 66 ++++++- blocks/file-blocks/markdown-edit/theme.tsx | 16 +- 7 files changed, 276 insertions(+), 46 deletions(-) diff --git a/blocks/file-blocks/markdown-edit/block-component-widget.tsx b/blocks/file-blocks/markdown-edit/block-component-widget.tsx index 9fa670d..6357120 100644 --- a/blocks/file-blocks/markdown-edit/block-component-widget.tsx +++ b/blocks/file-blocks/markdown-edit/block-component-widget.tsx @@ -98,7 +98,6 @@ export const blockComponentWidget = ({ const decorate = (state: EditorState) => { const widgets: Range[] = []; - // starts with any number of # syntaxTree(state).iterate({ enter: (type, from, to) => { @@ -178,11 +177,11 @@ export const blockComponentWidget = ({ return decorate(state); }, update(copys, transaction) { - if (transaction.docChanged) { - return decorate(transaction.state); - } + // if (transaction.docChanged) { + return decorate(transaction.state); + // } - return copys.map(transaction.changes); + // return copys.map(transaction.changes); }, provide(field) { return EditorView.decorations.from(field); diff --git a/blocks/file-blocks/markdown-edit/copy-widget.tsx b/blocks/file-blocks/markdown-edit/copy-widget.tsx index 730e7b7..4b27bff 100644 --- a/blocks/file-blocks/markdown-edit/copy-widget.tsx +++ b/blocks/file-blocks/markdown-edit/copy-widget.tsx @@ -1,20 +1,19 @@ import { syntaxTree } from "@codemirror/language"; import { Range, RangeSet } from "@codemirror/rangeset"; -import { EditorState, Extension, StateField } from "@codemirror/state"; +import { + CharCategory, + EditorSelection, + EditorState, + Extension, + StateField, +} from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, - WidgetType, + KeyBinding, } from "@codemirror/view"; -class HorizontalRuleWidget extends WidgetType { - toDOM() { - const hr = document.createElement("hr"); - return hr; - } -} - export const copy = (): Extension => { const headerDecoration = ({ level }: { level: string }) => Decoration.mark({ @@ -35,6 +34,21 @@ export const copy = (): Extension => { Decoration.mark({ class: "cm-copy-hr", }); + const blockquoteDecoration = () => + Decoration.line({ + class: "cm-copy-blockquote", + }); + const codeBlockDecoration = () => + Decoration.line({ + class: "cm-code", + }); + const listItemDecoration = (listType, index) => + Decoration.line({ + class: `cm-list-item cm-list-item--${listType}`, + attributes: { + "data-index": index, + }, + }); const decorate = (state: EditorState) => { const widgets: Range[] = []; @@ -43,6 +57,9 @@ export const copy = (): Extension => { const tree = syntaxTree(state); tree.iterate({ enter: (type, from, to) => { + // const text = state.doc.sliceString(from, to); + // useful for finding the type of some text + // console.log(type, text) if (type.name.startsWith("ATXHeading")) { const level = type.name.split("Heading")[1]; const newDecoration = headerDecoration({ level }); @@ -54,18 +71,37 @@ export const copy = (): Extension => { ); } else if (type.name === "SetextHeading2") { const newDecoration = horizontalRuleDecorationAfter(); - // const toPos = state.doc.lineAt(to).to widgets.push(newDecoration.range(from, to)); } else if (type.name === "Link") { const text = state.doc.sliceString(from, to); const linkRegex = /\[.*?\]\((?.*?)\)/; const url = linkRegex.exec(text)?.groups?.url; const linkTextRegex = /\[(?.*?)\]/; - const linkText = linkTextRegex.exec(text)?.groups?.text; + const linkText = linkTextRegex.exec(text)?.groups?.text || ""; if (url) { const newDecoration = linkDecoration(text, linkText, url); widgets.push(newDecoration.range(from, to)); } + } else if (type.name === "Blockquote") { + const newDecoration = blockquoteDecoration(); + widgets.push(newDecoration.range(from)); + } else if (type.name === "ListItem") { + const text = state.doc.sliceString(from, to); + console.log(type); + const listType = ["-", "*"].includes(text[0]) ? "ul" : "ol"; + const index = text.split(" ")[0]; + console.log({ text, index }); + const newDecoration = listItemDecoration(listType, index); + widgets.push(newDecoration.range(from)); + } else if (type.name === "CodeText") { + const newDecoration = codeBlockDecoration(); + const fromLine = state.doc.lineAt(from); + const toLine = state.doc.lineAt(to); + for (let i = fromLine.from; i < toLine.to; i++) { + const linePosition = state.doc.lineAt(i); + widgets.push(newDecoration.range(linePosition.from)); + } + // widgets.push(newDecoration.range(from)); } else if (type.name === "HTMLTag") { // punting for now, it splits the text into multiple widgets: // start tag, text, end tag @@ -99,8 +135,7 @@ export const copy = (): Extension => { return decorate(state); }, update(copys, transaction) { - console.log(transaction); - if (transaction.docChanged) { + if (transaction.docChanged || transaction.changes.length > 0) { return decorate(transaction.state); } @@ -113,3 +148,123 @@ export const copy = (): Extension => { return [copysTheme, copysField]; }; + +export const markdownKeymap: KeyBinding[] = [ + // text formatting + { + key: "Mod-b", + run: (view) => toggleWrapSelectionWithSymbols(view, "**"), + }, + { + key: "`", + run: (view) => toggleWrapSelectionWithSymbols(view, "`"), + }, + { + key: "Mod-i", + run: (view) => toggleWrapSelectionWithSymbols(view, "_"), + }, +]; + +const toggleWrapSelectionWithSymbols = (view: EditorView, symbols: string) => { + const selection = view.state.selection; + let runningDiff = 0; // to keep track of previous changes with multiple selections + + selection.ranges.forEach((range, i) => { + let from = range.from + runningDiff; + let to = range.to + runningDiff; + let text = view.state.doc.sliceString(from, to); + + if (!text) { + // select word at cursor + const edgeOfWordLeft = moveBySubword(view, range, false).from; + const edgeOfWordRight = moveBySubword(view, range, true).from; + const word = view.state.doc.sliceString(edgeOfWordLeft, edgeOfWordRight); + if (word) { + from = edgeOfWordLeft; + to = edgeOfWordRight; + text = word; + } + } + + let isWrappedBySymbols = text.startsWith(symbols) && text.endsWith(symbols); + + if (!isWrappedBySymbols) { + // check if the symbols are just outside the selection + const surroundingText = view.state.doc.sliceString( + from - symbols.length, + to + symbols.length + ); + const isSurroundedBySymbols = + surroundingText.startsWith(symbols) && + surroundingText.endsWith(symbols); + if (isSurroundedBySymbols) { + from -= symbols.length; + to += symbols.length; + text = view.state.doc.sliceString(from, to); + isWrappedBySymbols = true; + } + } + + const newText = isWrappedBySymbols + ? text.slice(symbols.length, -symbols.length) + : symbols + text + symbols; + const textDiff = newText.length - text.length; + runningDiff += textDiff; + + // change the active selection to just inside the symbols (or removed symbols) + const newFrom = textDiff > 0 ? from + textDiff / 2 : from; + const newTo = textDiff > 0 ? to + textDiff / 2 : to + textDiff; + + // update the state + const newRange = EditorSelection.range(newFrom, newTo); + let newState = view.state.update({ + changes: { from, to, insert: newText }, + selection: view.state.selection.replaceRange(newRange, i), + }); + view.dispatch(newState); + }); + + return true; // return true to always use this behavior +}; + +// nabbed from @codemirror/commands/dist/index.js +function moveBySubword(view, range, forward) { + let categorize = view.state.charCategorizer(range.from); + return view.moveByChar(range, forward, (start) => { + let cat = CharCategory.Space, + pos = range.from; + let done = false, + sawUpper = false, + sawLower = false; + let step = (next) => { + if (done) return false; + pos += forward ? next.length : -next.length; + let nextCat = categorize(next), + ahead; + if (cat == CharCategory.Space) cat = nextCat; + if (cat != nextCat) return false; + if (cat == CharCategory.Word) { + if (next.toLowerCase() == next) { + if (!forward && sawUpper) return false; + sawLower = true; + } else if (sawLower) { + if (forward) return false; + done = true; + } else { + if ( + sawUpper && + forward && + categorize((ahead = view.state.sliceDoc(pos, pos + 1))) == + CharCategory.Word && + ahead.toLowerCase() == ahead + ) + return false; + sawUpper = true; + } + } + return true; + }; + step(start); + return step; + }); +} diff --git a/blocks/file-blocks/markdown-edit/highlightActiveLine.ts b/blocks/file-blocks/markdown-edit/highlightActiveLine.ts index 321b5dd..34a942d 100644 --- a/blocks/file-blocks/markdown-edit/highlightActiveLine.ts +++ b/blocks/file-blocks/markdown-edit/highlightActiveLine.ts @@ -32,8 +32,8 @@ const activeLineHighlighter = ViewPlugin.fromClass( let lastLineStart = -1, deco = []; for (let r of view.state.selection.ranges) { + // commenting out this line to add class when there are multiple selections // if (!r.empty) return Decoration.none; - console.log(view.state.selection.ranges); let line = view.lineBlockAt(r.head); if (line.from > lastLineStart) { deco.push(lineDeco.range(line.from)); diff --git a/blocks/file-blocks/markdown-edit/image-widget.tsx b/blocks/file-blocks/markdown-edit/image-widget.tsx index df4e83f..6d4ca46 100644 --- a/blocks/file-blocks/markdown-edit/image-widget.tsx +++ b/blocks/file-blocks/markdown-edit/image-widget.tsx @@ -59,17 +59,30 @@ class ImageWidget extends WidgetType { return container; } + + ignoreEvent(_event: Event): boolean { + return false; + } } export const images = (): Extension => { - const imageRegex = /!\[.*?\]\((?.*?)\)/; - const imageRegexHtml = /.*?)".*?>/; + const imageRegex = /!\[(?.*?)\]\((?.*?)\)/; + const imageRegexHtml = /.*?)".*?alt="(?.*?)".*?>/; const imageDecoration = (imageWidgetParams: ImageWidgetParams) => Decoration.widget({ widget: new ImageWidget(imageWidgetParams), side: 1, block: true, + class: "image", + }); + + const imageTextDecoration = (alt: string) => + Decoration.mark({ + class: "cm-image", + attributes: { + title: alt, + }, }); const decorate = (state: EditorState) => { @@ -86,6 +99,12 @@ export const images = (): Extension => { state.doc.lineAt(from).from ) ); + widgets.push( + imageTextDecoration(result.groups.alt || result.groups.url).range( + state.doc.lineAt(from).from, + state.doc.lineAt(to).to + ) + ); } } else if (type.name === "HTMLBlock") { const result = imageRegexHtml.exec(state.doc.sliceString(from, to)); @@ -96,6 +115,13 @@ export const images = (): Extension => { state.doc.lineAt(from).from ) ); + console.log(result); + widgets.push( + imageTextDecoration(result.groups.alt || result.groups.url).range( + state.doc.lineAt(from).from, + state.doc.lineAt(to).to + ) + ); } } }, @@ -115,11 +141,13 @@ export const images = (): Extension => { return decorate(state); }, update(images, transaction) { - if (transaction.docChanged) { - return decorate(transaction.state); - } + // taking out restrictions for now, + // it wasn't updating outside of the active scroll window + // if (transaction.docChanged) { + return decorate(transaction.state); + // } - return images.map(transaction.changes); + // return images.map(transaction.changes); }, provide(field) { return EditorView.decorations.from(field); diff --git a/blocks/file-blocks/markdown-edit/index.tsx b/blocks/file-blocks/markdown-edit/index.tsx index 1563aed..ccf1e7e 100644 --- a/blocks/file-blocks/markdown-edit/index.tsx +++ b/blocks/file-blocks/markdown-edit/index.tsx @@ -29,12 +29,13 @@ import { } from "@codemirror/view"; import { Block } from "@githubnext/blocks"; import interact from "@replit/codemirror-interact"; -import { copy } from "./copy-widget"; +import { copy, markdownKeymap } from "./copy-widget"; import { blockComponentWidget } from "./block-component-widget"; import { images } from "./image-widget"; import { highlightActiveLine } from "./highlightActiveLine"; import { theme } from "./theme"; -const languageConf = new Compartment(); + +// TODO: code block syntax highlighting const extensions = [ // lineNumbers(), @@ -82,7 +83,6 @@ export default function (props: FileBlockProps) { isEditable, onUpdateContent, onRequestBlocksRepos, - BlockComponent, } = props; const editorRef = React.useRef(null); @@ -185,7 +185,6 @@ export default function (props: FileBlockProps) { React.useEffect(() => { if (viewRef.current || !editorRef.current) return; const onDispatchChanges = (changes: any) => { - console.log(changes); if (viewRef.current) viewRef.current.dispatch(changes); }; const state = EditorState.create({ @@ -219,6 +218,7 @@ export default function (props: FileBlockProps) { return true; }, }, + ...markdownKeymap, ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, diff --git a/blocks/file-blocks/markdown-edit/style.css b/blocks/file-blocks/markdown-edit/style.css index afbc028..61a4655 100644 --- a/blocks/file-blocks/markdown-edit/style.css +++ b/blocks/file-blocks/markdown-edit/style.css @@ -68,6 +68,38 @@ body, border-top: 1px solid rgb(225, 228, 232); transform: translate(0, 0.7em); } +.cm-editor .cm-copy-blockquote { + position: relative; + display: block; + color: #57606a; + border-left: 0.25em solid #d0d7de; + padding: 0.6em 0.9em; + background: #f6f8fa; +} + +.cm-code { + background: #f6f8fa; +} +.cm-code .ͼx { + background: transparent; +} +.cm-code.cm-activeLine + .cm-line .cm-instruction { + display: inline-block; +} +.cm-editor .cm-list-item { + display: list-item; + margin: 0.6em 0 0.9em 1em; +} +.cm-editor .cm-list-item--ul::marker { + content: "•"; +} +.cm-editor .cm-list-item--ol { + display: list-item; + list-style-type: decimal; +} +.cm-editor .cm-list-item--ol::marker { + content: attr(data-index); +} .cm-autocomplete-wrapper { position: absolute; @@ -85,37 +117,51 @@ body, .cm-line .BlockComponentWrapper input { caret-color: #000; } -.cm-editor .cm-line::selection, -.cm-editor .cm-activeLine::selection, -.cm-editor .ͼy::selection { - background: rgba(84, 174, 255, 0.4) !important; -} .cm-line .BlockComponentWrapper input::selection { background: rgba(84, 174, 255, 0.4) !important; } -.cm-copy-header .ͼ15 { - display: inline-block; +.cm-line .cm-instruction { + display: none; + color: rgb(175, 178, 182); +} +.cm-copy-header .cm-instruction { + display: inline-block !important; width: 0; opacity: 0; - margin-right: -0.3em; + margin-right: -0.5ch; } +.cm-image span, .cm-copy-link span { display: none; } +.cm-image:first-of-type:after, .cm-copy-link:after { content: attr(title); - color: #0550ae; + color: #0969da; +} +.cm-image:first-of-type:after { + margin: auto; + text-align: center; + display: block; +} +.cm-image:first-of-type:after { + color: rgb(75, 78, 82); + font-style: italic; } -.cm-activeLine .ͼ15, +.cm-activeLine .cm-instruction, .cm-activeLine .ͼu, +.cm-activeLine .cm-image span, +.cm-activeLine + .cm-image-container + .cm-line .cm-image span, .cm-activeLine .cm-copy-link span { display: inline-block; width: auto; opacity: 1; margin-right: 0; } +.cm-activeLine + .cm-image-container + .cm-line .cm-image:after, +.cm-activeLine .cm-image:first-of-type:after, .cm-activeLine .cm-copy-link:after { display: none; } diff --git a/blocks/file-blocks/markdown-edit/theme.tsx b/blocks/file-blocks/markdown-edit/theme.tsx index b1d64fd..c595204 100644 --- a/blocks/file-blocks/markdown-edit/theme.tsx +++ b/blocks/file-blocks/markdown-edit/theme.tsx @@ -2,8 +2,6 @@ import { EditorView } from "@codemirror/view"; import { Extension } from "@codemirror/state"; import { HighlightStyle } from "@codemirror/highlight"; import { tags as t } from "@lezer/highlight"; -import { parser } from "@lezer/markdown"; -console.log(parser); import primer from "@primer/primitives"; const colors = primer.colors.light.codemirror; @@ -39,7 +37,7 @@ export const colorTheme = EditorView.theme( backgroundColor: "#6199ff2f", }, - ".cm-activeLine": { backgroundColor: colors.bg }, + ".cm-activeLine": { backgroundColor: "transparent" }, ".cm-selectionMatch": { backgroundColor: colors.selectionBg }, @@ -49,7 +47,8 @@ export const colorTheme = EditorView.theme( }, ".cm-gutters": { - backgroundColor: colors.guttersBg, + // backgroundColor: colors.guttersBg, + backgroundColor: "transparent", color: colors.guttermarkerSubtleText, border: "none", }, @@ -99,6 +98,10 @@ export const highlightStyle = HighlightStyle.define([ tag: [t.function(t.variableName), t.labelName], color: colors.syntax.entity, }, + { + tag: [t.list], + class: "cm-list", + }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: colors.syntax.string, @@ -131,7 +134,7 @@ export const highlightStyle = HighlightStyle.define([ }, { tag: t.quote, - // paddingLeft: "2em", + class: "cm-quote", }, { tag: t.emphasis, @@ -162,8 +165,7 @@ export const highlightStyle = HighlightStyle.define([ }, { tag: [t.processingInstruction, t.inserted], - color: primer.colors.light.primer.canvas.backdrop, - opacity: 0.5, + class: "cm-instruction", }, { tag: [t.special(t.variableName), t.special(t.propertyName)], From 2a1a137ed70182cb1193cc277e14156abc15296c Mon Sep 17 00:00:00 2001 From: Amelia Wattenberger Date: Fri, 5 Aug 2022 12:42:48 -0700 Subject: [PATCH 3/9] markdown: add height to blockcomponent --- .../markdown-edit/block-component-widget.tsx | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/blocks/file-blocks/markdown-edit/block-component-widget.tsx b/blocks/file-blocks/markdown-edit/block-component-widget.tsx index 6357120..2fe86b2 100644 --- a/blocks/file-blocks/markdown-edit/block-component-widget.tsx +++ b/blocks/file-blocks/markdown-edit/block-component-widget.tsx @@ -27,6 +27,8 @@ import { ThemeProvider, } from "@primer/react"; import React, { + EventHandler, + MouseEventHandler, useCallback, useEffect, useMemo, @@ -197,11 +199,28 @@ const BlockComponentWrapper = ({ onChangeProps, }: { parentProps: FileBlockProps; - props: Partial; - onChangeProps: (newProps: Partial) => void; + props: Partial & { height?: number }; + onChangeProps: (newProps: Partial & { height?: number }) => void; }) => { + const resizingStart = useRef(null); + const [resizingHeight, setResizingHeight] = useState( + undefined + ); + const resizingHeightRef = useRef(undefined); + const eventHandlers = useRef<[string, (e: any) => void][]>([]); + useEffect(() => { + resizingHeightRef.current = resizingHeight; + }, [resizingHeight]); const BlockComponent = parentProps.BlockComponent; + useEffect(() => { + return () => { + eventHandlers.current.forEach((handler) => { + window.removeEventListener(handler[0], handler[1]); + }); + }; + }, []); + return ( // @ts-ignore @@ -211,16 +230,57 @@ const BlockComponentWrapper = ({ height: "100%", }} > -
+
- + + {!!resizingHeight && ( + // to keep the pointer events in this window +
+ )} + { + const { clientY } = e; + resizingStart.current = clientY; + + const onMouseMove = (e) => { + if (!resizingStart.current) return; + const { clientY } = e; + const diff = clientY - resizingStart.current; + setResizingHeight((props.height || 300) + diff); + }; + const onMouseUp = () => { + onChangeProps({ + ...props, + height: resizingHeightRef.current, + }); + setResizingHeight(undefined); + resizingStart.current = null; + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + eventHandlers.current = [ + ["mousemove", onMouseMove], + ["mouseup", onMouseUp], + ]; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + }} + />
From 42e3e487dc8554158f261189bb1ce28ce3252e96 Mon Sep 17 00:00:00 2001 From: Amelia Wattenberger Date: Fri, 5 Aug 2022 13:04:08 -0700 Subject: [PATCH 4/9] markdown: dont rerender blockcomponent on height change --- .../markdown-edit/block-component-widget.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/blocks/file-blocks/markdown-edit/block-component-widget.tsx b/blocks/file-blocks/markdown-edit/block-component-widget.tsx index 2fe86b2..ab2cff0 100644 --- a/blocks/file-blocks/markdown-edit/block-component-widget.tsx +++ b/blocks/file-blocks/markdown-edit/block-component-widget.tsx @@ -78,7 +78,14 @@ class BlockWidget extends WidgetType { } eq(_widget: WidgetType): boolean { - return JSON.stringify(this.props) === JSON.stringify(_widget.props); + const removeUnnecessaryProps = (props: Record) => { + const { height, ...rest } = props; + return rest; + }; + return ( + JSON.stringify(removeUnnecessaryProps(this.props)) === + JSON.stringify(removeUnnecessaryProps(_widget.props)) + ); } ignoreEvent(_event: Event): boolean { @@ -206,6 +213,7 @@ const BlockComponentWrapper = ({ const [resizingHeight, setResizingHeight] = useState( undefined ); + const resizingHeightRef = useRef(undefined); const eventHandlers = useRef<[string, (e: any) => void][]>([]); useEffect(() => { @@ -213,6 +221,14 @@ const BlockComponentWrapper = ({ }, [resizingHeight]); const BlockComponent = parentProps.BlockComponent; + // so we don't have to re-render + const [overrideHeight, setOverrideHeight] = useState( + props.height + ); + useEffect(() => { + setOverrideHeight(props.height); + }, [props.height]); + useEffect(() => { return () => { eventHandlers.current.forEach((handler) => { @@ -240,7 +256,7 @@ const BlockComponentWrapper = ({ @@ -261,13 +277,16 @@ const BlockComponentWrapper = ({ if (!resizingStart.current) return; const { clientY } = e; const diff = clientY - resizingStart.current; - setResizingHeight((props.height || 300) + diff); + setResizingHeight( + (overrideHeight || props.height || 300) + diff + ); }; const onMouseUp = () => { onChangeProps({ ...props, height: resizingHeightRef.current, }); + setOverrideHeight(resizingHeightRef.current); setResizingHeight(undefined); resizingStart.current = null; window.removeEventListener("mousemove", onMouseMove); From c25127350fe98a67b642f8b9832871f590c7fc97 Mon Sep 17 00:00:00 2001 From: Amelia Wattenberger Date: Mon, 8 Aug 2022 12:15:25 -0700 Subject: [PATCH 5/9] add sandbox block, make markdown more robust --- blocks.config.json | 16 +- .../markdown-edit/block-component-widget.tsx | 69 ++++++--- .../file-blocks/markdown-edit/copy-widget.tsx | 66 +++++++-- .../markdown-edit/image-widget.tsx | 140 ++++++++++++------ blocks/file-blocks/markdown-edit/index.tsx | 8 +- blocks/file-blocks/markdown-edit/style.css | 42 +++--- blocks/file-blocks/sandbox/index.tsx | 88 +++++++++++ package.json | 2 + yarn.lock | 36 ++++- 9 files changed, 359 insertions(+), 108 deletions(-) create mode 100644 blocks/file-blocks/sandbox/index.tsx diff --git a/blocks.config.json b/blocks.config.json index 3ea60dd..341f53d 100644 --- a/blocks.config.json +++ b/blocks.config.json @@ -120,8 +120,8 @@ { "type": "file", "id": "markdown-edit-block", - "title": "Markdown Edit block", - "description": "Edit Markdown content like rich text", + "title": "Markdown block", + "description": "View and edit Markdown content", "sandbox": false, "entry": "blocks/file-blocks/markdown-edit/index.tsx", "matches": ["*"], @@ -129,12 +129,12 @@ }, { "type": "file", - "id": "markdown-block", - "title": "Markdown", - "description": "View markdown files. You can also view live repo info, using Issues, Releases, and Commits custom components, as well as live code examples with CodeSandbox.", - "sandbox": true, - "entry": "blocks/file-blocks/live-markdown/index.tsx", - "matches": ["*.md", "*.mdx"], + "id": "sandbox-block", + "title": "JS Sandbox block", + "description": "Execute Javascript code", + "sandbox": false, + "entry": "blocks/file-blocks/sandbox/index.tsx", + "matches": ["*"], "example_path": "https://github.com/githubnext/blocks-tutorial/blob/main/README.md" }, { diff --git a/blocks/file-blocks/markdown-edit/block-component-widget.tsx b/blocks/file-blocks/markdown-edit/block-component-widget.tsx index ab2cff0..3c332c1 100644 --- a/blocks/file-blocks/markdown-edit/block-component-widget.tsx +++ b/blocks/file-blocks/markdown-edit/block-component-widget.tsx @@ -23,6 +23,7 @@ import { Autocomplete, BaseStyles, Box, + Button, FormControl, ThemeProvider, } from "@primer/react"; @@ -236,6 +237,7 @@ const BlockComponentWrapper = ({ }); }; }, []); + console.log({ props }); return ( // @ts-ignore @@ -426,6 +428,8 @@ const ContextControls = ({ [parentProps.onRequestBlocksRepos, blocksRepo] ); + const [isUsingCustomContent, setIsUsingCustomContent] = useState(false); + return ( - { - onChangeProps({ - ...props, - context: { ...combinedContext, ...newValue }, - }); - }} - /> - { - onChangeProps({ - ...props, - context: { ...combinedContext, ...newValue }, - }); - }} - /> + {isUsingCustomContent ? ( +