From efa0e00b340da7587fb8cdf0681e078838b4cecd Mon Sep 17 00:00:00 2001 From: Uri Shaked Date: Mon, 27 Jul 2020 21:20:04 +0300 Subject: [PATCH 01/14] feat: code annotations initial implementation --- components/annotated-source.tsx | 172 ++++++++++++++++++++++++ content/simon/sketch.ino | 23 +++- pages/api/download-project/[project].ts | 4 +- pages/projects/[id].tsx | 19 ++- services/gac-annotations.spec.ts | 52 +++++++ services/gac-annotations.ts | 72 ++++++++++ 6 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 components/annotated-source.tsx create mode 100644 services/gac-annotations.spec.ts create mode 100644 services/gac-annotations.ts diff --git a/components/annotated-source.tsx b/components/annotated-source.tsx new file mode 100644 index 0000000..ee0242c --- /dev/null +++ b/components/annotated-source.tsx @@ -0,0 +1,172 @@ +import { IGACAnnotation } from '../services/gac-annotations'; +import Highlight from 'react-highlight'; +import { useRef, useState, useMemo } from 'react'; +import ReactMarkdown from 'react-markdown/with-html'; + +const codeLineHeight = 25.5; +const lineOffset = (line: number) => line * codeLineHeight; + +interface IAnnotatedSourceProps { + code: string; + annotations: IGACAnnotation[]; +} + +function annotationLines(annotation: IGACAnnotation) { + const result = []; + for (let line = annotation.line; line <= annotation.endLine; line++) { + result.push(line); + } + return result; +} + +function AnnotationMarkers({ annotations }: { annotations: IGACAnnotation[] }) { + const result = []; + for (const annotation of annotations) { + for (const line of annotationLines(annotation)) { + result[line - 1] = ( +
+   +
+ ); + } + } + for (let line = 0; line < result.length; line++) { + result[line] = result[line] ??
 
; + } + return <>{result}; +} + +export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { + const codeBoxRef = useRef(null); + const [activeAnnotation, setActiveAnnotation] = useState(null); + + const highlightedCode = useMemo(() => {code}, [code]); + const annotatedLines = useMemo(() => { + const result = new Map(); + for (const annotation of annotations) { + for (const line of annotationLines(annotation)) { + result.set(line, annotation); + } + } + return result; + }, annotations); + + const handleMouseOver = (e: React.MouseEvent) => { + const codeBox = codeBoxRef.current; + if (!codeBox) { + return; + } + const { target } = e; + if (target instanceof HTMLElement && target.closest('.annotation-info')) { + return; + } + const line = Math.floor((e.pageY - codeBox.offsetTop) / codeLineHeight) + 1; + setActiveAnnotation(annotatedLines.get(line) ?? null); + }; + + return ( +
+
+   +
+
+
+
+ +
+ {highlightedCode} + {activeAnnotation && ( +
+ (url.startsWith('#') ? '' : '_blank')} + /> +
+ )} + +
+ ); +} diff --git a/content/simon/sketch.ino b/content/simon/sketch.ino index 02b2dc3..4929db0 100644 --- a/content/simon/sketch.ino +++ b/content/simon/sketch.ino @@ -3,19 +3,29 @@ Copyright (C) 2016, Uri Shaked - Licensed under the MIT License. + Released under the MIT License. */ +/* gac: `pitches.h` defines constants for the different music notes */ #include "pitches.h" /* Constants - define pin numbers for LEDs, buttons and speaker, and also the game tones: */ +/* gac:start + ✅ Define pin numbers as constants at the beginning of the program +*/ char ledPins[] = {9, 10, 11, 12}; char buttonPins[] = {2, 3, 4, 5}; #define SPEAKER_PIN 8 +/* gac:end */ #define MAX_GAME_LENGTH 100 +/* gac: The tone for each button is stored into an array, + so we can easily match each LED, button and their corresponding + note by looking at a specific index of the `ledPins`, `buttonPins`, + and `gameTones` arrays. +*/ int gameTones[] = { NOTE_G3, NOTE_C4, NOTE_E4, NOTE_G5}; /* Global variales - store the game state */ @@ -27,13 +37,24 @@ byte gameIndex = 0; */ void setup() { Serial.begin(9600); + /* gac:start + Defining the pins for the LEDs and Buttons in an array allows + us to initialize them using a `for` loop, instead of calling + the `pinMode()` function for each individual button / LED. + */ for (int i = 0; i < 4; i++) { pinMode(ledPins[i], OUTPUT); pinMode(buttonPins[i], INPUT_PULLUP); } + /* gac:end */ pinMode(SPEAKER_PIN, OUTPUT); // The following line primes the random number generator. // It assumes pin A0 is floating (disconnected): + /* gac: In arduino, the `random()` function will return the sequence + of numbers every time you start your program, unless you call + `randomSeem()` with a different value each time. To learn more about + this trick, check out the ["Making random() more Random" + video](https://www.youtube.com/watch?v=FwnXqZB2eo8). */ randomSeed(analogRead(A0)); } diff --git a/pages/api/download-project/[project].ts b/pages/api/download-project/[project].ts index d7d0fad..248ac85 100644 --- a/pages/api/download-project/[project].ts +++ b/pages/api/download-project/[project].ts @@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { promisify } from 'util'; import ZipStream from 'zip-stream'; import { getProjectCode } from '../../../services/projects'; +import { extractCodeAnnotations } from '../../../services/gac-annotations'; export default async (req: NextApiRequest, res: NextApiResponse) => { const { @@ -17,7 +18,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { zip.pipe(res); for (const file of await getProjectCode(projectId)) { - await promisify(zip.entry).call(zip, file.code, { name: file.name }); + const { code } = extractCodeAnnotations(file.code); + await promisify(zip.entry).call(zip, code, { name: file.name }); } zip.finalize(); }; diff --git a/pages/projects/[id].tsx b/pages/projects/[id].tsx index bdefa71..e28b3f2 100644 --- a/pages/projects/[id].tsx +++ b/pages/projects/[id].tsx @@ -3,12 +3,14 @@ import { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { ParsedUrlQuery } from 'querystring'; import React from 'react'; -import Highlight from 'react-highlight'; import ReactMarkdown from 'react-markdown/with-html'; +import { AnnotatedSource } from '../../components/annotated-source'; import { GlobalStyles } from '../../components/global-styles'; import { Header } from '../../components/header'; import { LinkIconButton } from '../../components/link-icon-button'; import { ISideNavLink, SideNav } from '../../components/side-nav'; +import { reportEvent } from '../../services/analytics'; +import { extractCodeAnnotations, IGACAnnotation } from '../../services/gac-annotations'; import { getProject, getProjectCode, @@ -20,19 +22,22 @@ import { projectFileURL } from '../../services/urls'; import { HeadingRenderer } from '../../utils/heading-renderer'; import { headingToId } from '../../utils/heading-to-id'; import { extractHeadings } from '../../utils/markdown-utils'; -import { reportEvent } from '../../services/analytics'; interface ProjectPageParams extends ParsedUrlQuery { id: string; } +interface IAnnotatedSourceFile extends IProjectSourceFile { + annotations: IGACAnnotation[]; +} + interface ProjectPageProps { id: string; name: string; author?: string; description?: string; simulation: string | null; - code: IProjectSourceFile[]; + code: IAnnotatedSourceFile[]; text: string; } @@ -132,7 +137,7 @@ export default function ProjectPage(props: ProjectPageProps) { {props.code.map((file) => (

{file.name}

- {file.code} +
))} {props.simulation && ( @@ -226,6 +231,10 @@ export const getStaticProps: GetStaticProps throw new Error('Missing post id'); } const project = await getProject(params.id); + const annotatedCode = (await getProjectCode(params.id)).map((sourceFile) => { + const { code, annotations } = extractCodeAnnotations(sourceFile.code); + return { ...sourceFile, code, annotations }; + }); return { props: { id: params.id, @@ -234,7 +243,7 @@ export const getStaticProps: GetStaticProps description: project.description, simulation: project.simulation ?? null, text: await getProjectText(params.id), - code: await getProjectCode(params.id), + code: annotatedCode, }, }; }; diff --git a/services/gac-annotations.spec.ts b/services/gac-annotations.spec.ts new file mode 100644 index 0000000..e35188d --- /dev/null +++ b/services/gac-annotations.spec.ts @@ -0,0 +1,52 @@ +import { extractCodeAnnotations } from './gac-annotations'; + +describe('extractCodeAnnotations', () => { + it('should split the given source file into Code and GAC annotations', () => { + const input = ` + /* gac: simple annotation */ + int speakerPin = 7; + + /* gac: This annotation applies for + a single line + */ + /* This is a standard comment */ + void setup() {} + + /* gac:start + This is the loop function... + */ + void loop() { + + } + /* gac:end */ + `; + + const expectedCode = ` + int speakerPin = 7; + + /* This is a standard comment */ + void setup() {} + + void loop() { + + } + `; + + expect(extractCodeAnnotations(input)).toEqual({ + code: expectedCode, + annotations: [ + { + line: 2, + endLine: 2, + value: 'simple annotation', + }, + { + line: 4, + endLine: 4, + value: 'This annotation applies for\na single line', + }, + { line: 7, endLine: 9, value: 'This is the loop function...' }, + ], + }); + }); +}); diff --git a/services/gac-annotations.ts b/services/gac-annotations.ts new file mode 100644 index 0000000..1fa4eaf --- /dev/null +++ b/services/gac-annotations.ts @@ -0,0 +1,72 @@ +export interface IGACAnnotation { + line: number; + endLine: number; + value: string; +} + +const keywords = { + gac: 'gac:', + gacStart: 'gac:start', + gacEnd: 'gac:end', +} as const; + +export function extractCodeAnnotations(source: string) { + const code = []; + const annotations: IGACAnnotation[] = []; + let lineNumber = 1; + let currentIndent = 0; + let currentAnnotation: IGACAnnotation | null = null; + let insideComment = false; + let insideSection = false; + for (const line of source.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('/*')) { + const firstWord = trimmed.substr(2).trim().split(' ')[0]; + if (firstWord === keywords.gac || firstWord === keywords.gacStart) { + insideComment = true; + insideSection = firstWord === keywords.gacStart; + const content = trimmed + .substr(trimmed.indexOf(firstWord) + firstWord.length) + .trim() + .replace(/\s*\*\/.*$/, ''); + currentAnnotation = { + line: lineNumber, + endLine: lineNumber, + value: content, + }; + annotations.push(currentAnnotation); + if (trimmed.includes('*/') && !insideSection) { + insideComment = false; + currentAnnotation = null; + } + currentIndent = line.indexOf(firstWord); + continue; + } + if (firstWord === keywords.gacEnd && currentAnnotation) { + currentAnnotation.endLine = lineNumber - 1; + currentAnnotation = null; + continue; + } + } + if (insideComment && currentAnnotation) { + const content = line + .replace(new RegExp(`^ {0,${currentIndent}}`), '') + .replace(/\s*\*\/.*$/, ''); + currentAnnotation.value += '\n' + content; + if (trimmed.startsWith('*/') || trimmed.endsWith('*/')) { + currentAnnotation.value = currentAnnotation.value.trim(); + insideComment = false; + if (!insideSection) { + currentAnnotation = null; + } + } + continue; + } + code.push(line); + lineNumber++; + } + return { + code: code.join('\n'), + annotations, + }; +} From 59242ec2fa51c1d0ef10cd9afb98fd564ed2a889 Mon Sep 17 00:00:00 2001 From: AriellaE <35613240+AriellaE@users.noreply.github.com> Date: Tue, 28 Jul 2020 13:56:35 +0300 Subject: [PATCH 02/14] fix(simon): typo --- content/simon/sketch.ino | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/simon/sketch.ino b/content/simon/sketch.ino index 4929db0..c0e62fd 100644 --- a/content/simon/sketch.ino +++ b/content/simon/sketch.ino @@ -52,7 +52,7 @@ void setup() { // It assumes pin A0 is floating (disconnected): /* gac: In arduino, the `random()` function will return the sequence of numbers every time you start your program, unless you call - `randomSeem()` with a different value each time. To learn more about + `randomSeed()` with a different value each time. To learn more about this trick, check out the ["Making random() more Random" video](https://www.youtube.com/watch?v=FwnXqZB2eo8). */ randomSeed(analogRead(A0)); From 4339dbe7430719611bb244ee1492771cd865e61b Mon Sep 17 00:00:00 2001 From: AriellaE <35613240+AriellaE@users.noreply.github.com> Date: Tue, 28 Jul 2020 14:09:36 +0300 Subject: [PATCH 03/14] fix: adjust annotation animation time --- components/annotated-source.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/annotated-source.tsx b/components/annotated-source.tsx index ee0242c..2c4b84e 100644 --- a/components/annotated-source.tsx +++ b/components/annotated-source.tsx @@ -135,7 +135,7 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { position: absolute; background: #00ffc3; width: 100%; - transition: width 0.2s; + transition: width 0.7s; z-index: -1; } From 469f90262384b0ef7121a655e87c947144dd554d Mon Sep 17 00:00:00 2001 From: AriellaE <35613240+AriellaE@users.noreply.github.com> Date: Tue, 28 Jul 2020 14:17:42 +0300 Subject: [PATCH 04/14] fix: annotation stays when mouse leaves code area --- components/annotated-source.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/annotated-source.tsx b/components/annotated-source.tsx index 2c4b84e..f63be59 100644 --- a/components/annotated-source.tsx +++ b/components/annotated-source.tsx @@ -65,7 +65,12 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { }; return ( -
+
setActiveAnnotation(null)} + ref={codeBoxRef} + >
Date: Tue, 28 Jul 2020 18:04:30 +0300 Subject: [PATCH 05/14] fix: wrong annotation positioning annotation highlight / mask positioning was inaccurate on some screen sizes --- components/annotated-source.tsx | 41 ++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/components/annotated-source.tsx b/components/annotated-source.tsx index f63be59..1d093a3 100644 --- a/components/annotated-source.tsx +++ b/components/annotated-source.tsx @@ -1,11 +1,8 @@ import { IGACAnnotation } from '../services/gac-annotations'; import Highlight from 'react-highlight'; -import { useRef, useState, useMemo } from 'react'; +import { useRef, useState, useMemo, useLayoutEffect } from 'react'; import ReactMarkdown from 'react-markdown/with-html'; -const codeLineHeight = 25.5; -const lineOffset = (line: number) => line * codeLineHeight; - interface IAnnotatedSourceProps { code: string; annotations: IGACAnnotation[]; @@ -38,6 +35,8 @@ function AnnotationMarkers({ annotations }: { annotations: IGACAnnotation[] }) { export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { const codeBoxRef = useRef(null); + const markersRef = useRef(null); + const [activeAnnotation, setActiveAnnotation] = useState(null); const highlightedCode = useMemo(() => {code}, [code]); @@ -51,6 +50,18 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { return result; }, annotations); + const [codeLineHeight, setCodeLineHeight] = useState(0); + const codeLineOffset = (line: number) => (line - 1) * codeLineHeight; + useLayoutEffect(() => { + const measure = () => + setCodeLineHeight( + markersRef.current?.firstElementChild?.getBoundingClientRect()?.height || 0, + ); + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, [markersRef.current]); + const handleMouseOver = (e: React.MouseEvent) => { const codeBox = codeBoxRef.current; if (!codeBox) { @@ -60,10 +71,13 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { if (target instanceof HTMLElement && target.closest('.annotation-info')) { return; } - const line = Math.floor((e.pageY - codeBox.offsetTop) / codeLineHeight) + 1; - setActiveAnnotation(annotatedLines.get(line) ?? null); + if (codeLineHeight) { + const line = Math.floor((e.pageY - codeBox.offsetTop) / codeLineHeight) + 1; + setActiveAnnotation(annotatedLines.get(line) ?? null); + } }; + const annotationOffset = codeLineOffset(activeAnnotation?.line ?? 1); return (
  @@ -85,17 +99,17 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) {
-
+
{highlightedCode} @@ -103,7 +117,7 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) {
{` .code-box { + line-height: 1.5; position: relative; display: flex; } @@ -126,7 +141,6 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { } .annotation-markers { - line-height: 1.5; width: 8px; } @@ -160,7 +174,6 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { position: absolute; background: white; padding: 0.5em; - line-height: 1.5; border: solid black 1px; width: 100%; } From 388c8be09aae392430e8e982c4d2b15badbbc4c7 Mon Sep 17 00:00:00 2001 From: Uri Shaked Date: Tue, 28 Jul 2020 18:08:11 +0300 Subject: [PATCH 06/14] fix(simon): mark const arrays as such The comment says they are constant, so let's actually make them constants --- content/simon/sketch.ino | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/simon/sketch.ino b/content/simon/sketch.ino index c0e62fd..07d05bb 100644 --- a/content/simon/sketch.ino +++ b/content/simon/sketch.ino @@ -14,19 +14,19 @@ /* gac:start ✅ Define pin numbers as constants at the beginning of the program */ -char ledPins[] = {9, 10, 11, 12}; -char buttonPins[] = {2, 3, 4, 5}; +const byte ledPins[] = {9, 10, 11, 12}; +const byte buttonPins[] = {2, 3, 4, 5}; #define SPEAKER_PIN 8 /* gac:end */ #define MAX_GAME_LENGTH 100 -/* gac: The tone for each button is stored into an array, +/* gac: The tone for each button is stored in an array, so we can easily match each LED, button and their corresponding - note by looking at a specific index of the `ledPins`, `buttonPins`, + tone by looking at a specific index of the `ledPins`, `buttonPins`, and `gameTones` arrays. */ -int gameTones[] = { NOTE_G3, NOTE_C4, NOTE_E4, NOTE_G5}; +const int gameTones[] = { NOTE_G3, NOTE_C4, NOTE_E4, NOTE_G5}; /* Global variales - store the game state */ byte gameSequence[MAX_GAME_LENGTH] = {0}; From 3911af38ed8cf32148531bdf768bf93556f91a2b Mon Sep 17 00:00:00 2001 From: Uri Shaked Date: Tue, 28 Jul 2020 18:30:43 +0300 Subject: [PATCH 07/14] fix: stray */ in annotation texts on Windows Parsing issue for code annotations on Windows machines due to CRLF line ending --- services/gac-annotations.spec.ts | 15 +++++++++++++++ services/gac-annotations.ts | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/services/gac-annotations.spec.ts b/services/gac-annotations.spec.ts index e35188d..192568c 100644 --- a/services/gac-annotations.spec.ts +++ b/services/gac-annotations.spec.ts @@ -49,4 +49,19 @@ describe('extractCodeAnnotations', () => { ], }); }); + + it('should handle CRLF in annotations', () => { + const input = '/* gac: hello\r\nworld */\r\nTest'; + + expect(extractCodeAnnotations(input)).toEqual({ + code: 'Test', + annotations: [ + { + line: 1, + endLine: 1, + value: 'hello\nworld', + }, + ], + }); + }); }); diff --git a/services/gac-annotations.ts b/services/gac-annotations.ts index 1fa4eaf..cf00dd3 100644 --- a/services/gac-annotations.ts +++ b/services/gac-annotations.ts @@ -28,7 +28,7 @@ export function extractCodeAnnotations(source: string) { const content = trimmed .substr(trimmed.indexOf(firstWord) + firstWord.length) .trim() - .replace(/\s*\*\/.*$/, ''); + .replace(/\s*\*\/.*$/s, ''); currentAnnotation = { line: lineNumber, endLine: lineNumber, @@ -51,7 +51,7 @@ export function extractCodeAnnotations(source: string) { if (insideComment && currentAnnotation) { const content = line .replace(new RegExp(`^ {0,${currentIndent}}`), '') - .replace(/\s*\*\/.*$/, ''); + .replace(/\s*\*\/.*$/s, ''); currentAnnotation.value += '\n' + content; if (trimmed.startsWith('*/') || trimmed.endsWith('*/')) { currentAnnotation.value = currentAnnotation.value.trim(); From 122d82c6f5a498c5e597857aed36430c6f3979fe Mon Sep 17 00:00:00 2001 From: Uri Shaked Date: Tue, 28 Jul 2020 18:36:56 +0300 Subject: [PATCH 08/14] fix: project page size on mobile fix the width of the project code box on mobile devices --- components/annotated-source.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/annotated-source.tsx b/components/annotated-source.tsx index 1d093a3..7516046 100644 --- a/components/annotated-source.tsx +++ b/components/annotated-source.tsx @@ -128,6 +128,7 @@ export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { )}