diff --git a/blocks.config.json b/blocks.config.json index bfe6e3a..ba10f4f 100644 --- a/blocks.config.json +++ b/blocks.config.json @@ -120,11 +120,21 @@ { "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"], + "title": "Markdown block", + "description": "View and edit Markdown content", + "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": "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/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..f1efa38 --- /dev/null +++ b/blocks/file-blocks/markdown-edit/block-component-widget.tsx @@ -0,0 +1,567 @@ +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, + Button, + FormControl, + ThemeProvider, +} from "@primer/react"; +import React, { + EventHandler, + MouseEventHandler, + 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 { + 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 { + 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[] = []; + + 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() + // remove start and end curly braces + .replace(/^\{|\}$/g, "")}` + ); + 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 & { 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; + + // 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) => { + window.removeEventListener(handler[0], handler[1]); + }); + }; + }, []); + + return ( + // @ts-ignore + + +
+
+ + + + {!!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( + (overrideHeight || props.height || 300) + diff + ); + }; + const onMouseUp = () => { + onChangeProps({ + ...props, + height: resizingHeightRef.current, + }); + setOverrideHeight(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); + }} + /> +
+
+ + + ); +}; + +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 iteration = useRef(0); + + const fetchData = async () => { + setIsLoading(true); + iteration.current++; + try { + const res = await fetch(); + if (iteration.current !== iteration.current) return; + 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 contentRepo = `${combinedContext.owner}/${combinedContext.repo}`; + const onFetchRepos = useCallback( + async (searchTerm: string) => { + const repos = await parentProps.onRequestGitHubData( + "/search/repositories", + { + sort: "stars", + direction: "desc", + per_page: 10, + q: searchTerm || "blocks", + } + ); + 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 onFetchRepoPaths = useCallback( + async (searchTerm: string) => { + const paths = await parentProps.onRequestGitHubData( + `/repos/${contentRepo}/contents`, + { + per_page: 100, + } + ); + 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..76a7bde --- /dev/null +++ b/blocks/file-blocks/markdown-edit/copy-widget.tsx @@ -0,0 +1,319 @@ +import { syntaxTree } from "@codemirror/language"; +import { Range, RangeSet } from "@codemirror/rangeset"; +import { + CharCategory, + EditorSelection, + EditorState, + Extension, + StateField, +} from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + KeyBinding, + WidgetType, +} from "@codemirror/view"; + +class LinkAltWidget extends WidgetType { + readonly text; + readonly linkText; + readonly url; + + constructor({ + text, + linkText, + url, + }: { + text: string; + linkText: string; + url: string; + }) { + super(); + + this.text = text; + this.linkText = linkText; + this.url = url; + } + + eq(widget: LinkAltWidget) { + return ( + widget.url === this.url && + widget.text === this.text && + widget.linkText === this.linkText + ); + } + + toDOM() { + const container = document.createElement("a"); + container.setAttribute("aria-hidden", "true"); + container.className = "cm-copy-link-alt"; + container.href = this.url; + container.target = "_blank"; + container.textContent = this.linkText; + + return container; + } + + ignoreEvent(_event: Event): boolean { + return false; + } +} +export const copy = (): Extension => { + const headerDecoration = ({ level }: { level: string }) => + Decoration.mark({ + class: `cm-copy-header cm-copy-header--${level}`, + }); + const linkAltDecoration = (text: string, linkText: string, url: string) => + Decoration.widget({ + widget: new LinkAltWidget({ text, linkText, url }), + class: "cm-copy-link-alt", + }); + const linkDecoration = (text: string, linkText: string, url: string) => + Decoration.mark({ + tagName: "a", + class: "cm-copy-link", + attributes: { + href: url, + target: "_top", + onclick: `window.open('${url}', '_blank'); return false;`, + title: linkText, + }, + }); + const horizontalRuleDecorationAfter = () => + 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[] = []; + + 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(); + 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 = linkAltDecoration(text, linkText, url); + widgets.push(newDecoration.range(from)); + const newAltDecoration = linkDecoration(text, linkText, url); + widgets.push(newAltDecoration.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); + const listType = ["-", "*"].includes(text[0]) ? "ul" : "ol"; + const index = text.split(" ")[0]; + 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") { + const text = state.doc.sliceString(from, to); + console.log(text); + const linkRegexHtml = + /.*?)".*?>(?.*?)[<\/a>]*/; + const result = linkRegexHtml.exec(text); + if (result && result.groups && result.groups.url) { + let linkText = result.groups.text; + const url = result.groups.url; + if (url) { + const newDecoration = linkAltDecoration(text, linkText, url); + widgets.push(newDecoration.range(from)); + const newAltDecoration = linkDecoration(text, linkText, url); + widgets.push(newAltDecoration.range(from, to)); + } + } else if (text === "") { + const newAltDecoration = linkDecoration(text, "", ""); + widgets.push(newAltDecoration.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) { + if (transaction.docChanged || transaction.changes.length > 0) { + return decorate(transaction.state); + } + + return copys.map(transaction.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + }, + }); + + 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 new file mode 100644 index 0000000..34a942d --- /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) { + // commenting out this line to add class when there are multiple selections + // if (!r.empty) return Decoration.none; + 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..e666383 --- /dev/null +++ b/blocks/file-blocks/markdown-edit/image-widget.tsx @@ -0,0 +1,214 @@ +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"; +import unescape from "lodash.unescape"; + +interface ImageWidgetParams { + url: string; + width: string | undefined; + height: string | undefined; +} + +class ImageWidget extends WidgetType { + readonly url; + readonly width; + readonly height; + + constructor({ url, width, height }: ImageWidgetParams) { + super(); + + this.url = url; + this.width = width; + this.height = height; + } + + eq(imageWidget: ImageWidget) { + return ( + imageWidget.url === this.url && + imageWidget.width === this.width && + imageWidget.height === this.height + ); + } + + toDOM() { + const figure = document.createElement("figure"); + const image = figure.appendChild(document.createElement("img")); + console.log(this); + + figure.className = "cm-image-container"; + image.src = this.url; + + figure.style.margin = "0"; + + const parseStyle = (style: string) => { + if (!style) return null; + if (Number.isFinite(+style)) return `${style}px`; + return style; + }; + image.style.width = parseStyle(this.width) || "100%"; + image.style.height = parseStyle(this.height) || "auto"; + + return figure; + } + + ignoreEvent(_event: Event): boolean { + return false; + } +} +class ImageAltWidget extends WidgetType { + readonly text; + + constructor({ text }: { text: string }) { + super(); + + this.text = text; + } + + eq(widget: ImageAltWidget) { + return widget.text === this.text; + } + + toDOM() { + const container = document.createElement("span"); + container.setAttribute("aria-hidden", "true"); + container.className = "cm-image-alt"; + container.textContent = unescape(this.text); + return container; + } + + ignoreEvent(_event: Event): boolean { + return false; + } +} + +export const images = (): Extension => { + const imageRegex = /!\[(?.*?)\]\((?.*?)\)/; + const imageRegexHtml = /.*?)".*?>/; + + 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", + }); + + const imageAltDecoration = (alt: string) => + Decoration.widget({ + widget: new ImageAltWidget({ text: alt }), + class: "cm-image-alt", + }); + + const decorate = (state: EditorState) => { + const widgets: Range[] = []; + + syntaxTree(state).iterate({ + enter: (type, from, to) => { + const text = state.doc.slice(from, to); + if (type.name === "Image") { + const result = imageRegex.exec(state.doc.sliceString(from, to)); + + if (result && result.groups && result.groups.url) { + const widthRegex = /width="(?.*?)"/; + const heightRegex = /height="(?.*?)"/; + const widthResult = widthRegex.exec(result.groups.url); + const heightResult = heightRegex.exec(result.groups.url); + console.log(state.doc.lineAt(from)); + widgets.push( + imageDecoration({ + url: result.groups.url, + width: widthResult?.groups?.width, + height: heightResult?.groups?.height, + }).range(state.doc.lineAt(from).from) + ); + widgets.push( + imageAltDecoration(result.groups.alt || result.groups.url).range( + state.doc.lineAt(to).to + ) + ); + widgets.push( + imageTextDecoration(result.groups.alt || result.groups.url).range( + state.doc.lineAt(from).from, + state.doc.lineAt(to).to + ) + ); + } + } else if (["HTMLBlock", "Paragraph"].includes(type.name)) { + const text = state.doc.sliceString(from, to); + const result = imageRegexHtml.exec(text); + + if (result && result.groups && result.groups.url) { + const widthRegex = /width="(?.*?)"/; + const heightRegex = /height="(?.*?)"/; + const widthResult = widthRegex.exec(text); + const heightResult = heightRegex.exec(text); + widgets.push( + imageDecoration({ + url: result.groups.url, + width: widthResult?.groups?.width, + height: heightResult?.groups?.height, + }).range(state.doc.lineAt(from).from) + ); + const altRegex = /alt="(?.*?)"/; + const altResult = altRegex.exec(text); + widgets.push( + imageAltDecoration( + altResult?.groups?.alt || result.groups.url + ).range(state.doc.lineAt(to).to) + ); + widgets.push( + imageTextDecoration( + altResult?.groups?.alt || result.groups.url + ).range(state.doc.lineAt(from).from, state.doc.lineAt(to).to) + ); + } + } + }, + }); + if (!widgets.length) return Decoration.none; + // we need to return the widgets in order + const sortedWidgets = widgets.sort((a, b) => { + if (a.from < b.from) return -1; + if (a.from > b.from) return 1; + return a.value.startSide < b.value.startSide ? -1 : 1; + }); + return RangeSet.of(sortedWidgets); + }; + + 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) { + // 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); + }, + 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..33617b2 --- /dev/null +++ b/blocks/file-blocks/markdown-edit/index.tsx @@ -0,0 +1,423 @@ +import { FileBlockProps } from "@githubnext/utils"; +import { ActionList, ActionMenu, Box, Link, Text } from "@primer/react"; +import React, { 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 { 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 { blockComponentWidget } from "./block-component-widget"; +import { copy, markdownKeymap } from "./copy-widget"; +import { highlightActiveLine } from "./highlightActiveLine"; +import { images } from "./image-widget"; +import { theme } from "./theme"; +import { + InfoIcon, + LinkExternalIcon, + RepoIcon, + VerifiedIcon, +} from "@primer/octicons-react"; + +// TODO: code block syntax highlighting + +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, + } = 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(() => { + 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) => { + 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; + }, + }, + ...markdownKeymap, + ...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 && ( +
+ { + setAutocompleteFocusedBlockIndex(index); + onSelectAutocompleteFocusedBlock.current(); + }} + onClose={() => { + setAutocompleteLocation(null); + setSearchTerm(""); + }} + /> +
+ )} +
+
+ ); +} + +const WidgetPicker = ({ + location, + isLoading, + blocks, + focusedBlockIndex, + onClose, + onSelect, + onFocus, +}: { + location?: DOMRect; + isLoading: boolean; + blocks: Block[]; + focusedBlockIndex: number; + onClose: () => void; + onFocus: (index: number) => void; + onSelect: (index: number) => void; +}) => { + return ( +
+ + + Open Actions Menu + + + {isLoading ? ( +
+ Loading... +
+ ) : !blocks.length ? ( +
+ No Blocks found +
+ ) : ( + + + {blocks.map((block, index) => { + const isExampleBlock = + [block.owner, block.repo].join("/") === + `githubnext/blocks-examples`; + return ( + //
because