NOTE
comments, and extracting the times for those with my-youtube-copy-chapters. It could be nice to capture the times on the fly. org-timer
could let me insert relative timestamps, but I think it might need some tweaking to synchronize that with when the stream starts according to YouTube. I've set up a capture, too, so I can take notes with timestamps.
It turns out that I don't have a lot of mental bandwidth when I'm on stream, so it's hard to remember keyboard shortcuts. (Maybe if I practise using the hydra I set up…) Fortunately, Org Mode's elisp:
link type makes it easy to set up executable shortcuts. For example, I can add links like [[elisp:my-stream-message-link][TODO]]
to my livestream plans like this:
I can then click on the links or use C-c C-o
(org-open-link-at-point
) to run the function. When I follow the TODO link in the first item, Emacs displays a clock and a message based on the rest of the line after the link.
In the background, the code also sets the description of the link to the wall-clock time.
If I start the livestream with a clock displayed on screen, I can use that to translate wall-clock times to relative time offsets. I'll probably figure out some Elisp to translate the times automatically at some point, maybe based on something like org-timer-change-times-in-region
.
I figured it might be fun to add a QR code automatically if we detect a URL, taking advantage of that qrencode package I started playing around with.
You can also use elisp:
links for more complicated Emacs Lisp functions, like this: elisp:(progn ... ...)
.
Here's the code that makes it happen. It's based on emacsconf-stream.el.
(defvar my-stream-message-buffer "*Yay Emacs*") (defvar my-stream-message-timer nil) (defun my-stream-message-link () (interactive) (save-excursion (when (and (derived-mode-p 'org-mode) (eq (org-element-type (org-element-context)) 'link)) (my-stream-update-todo-description-with-time) (goto-char (org-element-end (org-element-context))) (my-stream-message (org-export-string-as (buffer-substring (point) (line-end-position)) 'ascii t))))) (defun my-stream-update-todo-description-with-time () (when (and (derived-mode-p 'org-mode) (eq (org-element-type (org-element-context)) 'link)) (my-org-update-link-description (format-time-string "%-I:%M:%S %p")))) (defun my-stream-message (&optional message) (interactive "MMessage: ") ;; update the description of the link at point to be the current time, if any (switch-to-buffer (get-buffer-create my-stream-message-buffer)) (erase-buffer) (delete-other-windows) (when (string= message "") (setq message nil)) (face-remap-add-relative 'default :height 200) (insert "Yay Emacs! - Sacha Chua (sacha@sachachua.com)\n" (propertize "date" 'stream-time (lambda () (format-time-string "%Y-%m-%d %H:%M:%S %Z (%z)"))) "\n\n" message) ;; has a URL? Let's QR encode it! (when-let ((url (save-excursion (when (re-search-backward ffap-url-regexp nil t) (thing-at-point-url-at-point))))) (insert (propertize (qrencode url) 'face '(:height 50)) "\n")) (insert "\nYayEmacs.com\n") (when (timerp my-stream-message-timer) (cancel-timer my-stream-message-timer)) (my-stream-update-time) (setq my-stream-message-timer (run-at-time t 1 #'my-stream-update-time)) (goto-char (point-min))) (defun my-stream-update-time () "Update the displayed time." (if (get-buffer my-stream-message-buffer) (when (get-buffer-window my-stream-message-buffer) (with-current-buffer my-stream-message-buffer (save-excursion (goto-char (point-min)) (let (match) (while (setq match (text-property-search-forward 'stream-time)) (goto-char (prop-match-beginning match)) (add-text-properties (prop-match-beginning match) (prop-match-end match) (list 'display (funcall (get-text-property (prop-match-beginning match) 'stream-time)))) (goto-char (prop-match-end match))))))) (when (timerp my-stream-message-timer) (cancel-timer my-stream-message-timer))))
Let's see if that makes it easy enough for me to remember to actually do it!
]]>spookfox-js-injection-eval-in-active-tab
lets you evaluate Javascript and get the results back in Emacs Lisp.
I wanted to be able to execute code even more
easily. This code lets me add a :spookfox t
parameter to Org Babel Javascript blocks so that I
can run the block in my Firefox active tab.
For example, if I have (spookfox-init)
set up, Spookfox connected, and https://planet.emacslife.com in my active tab, I can use it with the following code:
#+begin_src js :eval never-export :spookfox t :exports results [...document.querySelectorAll('.post > h2')].slice(0,5).map((o) => '- ' + o.textContent.trim().replace(/[ \n]+/g, ' ') + '\n').join('') #+end_src
To do this, we wrap some advice around the org-babel-execute:js
function that's called by org-babel-execute-src-block
.
(defun my-org-babel-execute:js-spookfox (old-fn body params) "Maybe execute Spookfox." (if (assq :spookfox params) (spookfox-js-injection-eval-in-active-tab body t) (funcall old-fn body params))) (with-eval-after-load 'ob-js (advice-add 'org-babel-execute:js :around #'my-org-babel-execute:js-spookfox))
I can also run the block in Spookfox without adding the parameter if I make an interactive function:
(defun my-spookfox-eval-org-block () (interactive) (let ((block (org-element-context))) (when (and (eq (org-element-type block) 'src-block) (string= (org-element-property :language block) "js")) (spookfox-js-injection-eval-in-active-tab (nth 2 (org-src--contents-area block)) t))))
I can add that as an Embark context action:
(with-eval-after-load 'embark-org (define-key embark-org-src-block-map "f" #'my-spookfox-eval-org-block))
In Javascript buffers, I want the ability to send the current line, region, or buffer too, just like nodejs-repl does.
(defun my-spookfox-send-region (start end) (interactive "r") (spookfox-js-injection-eval-in-active-tab (buffer-substring start end) t)) (defun my-spookfox-send-buffer () (interactive) (my-spookfox-send-region (point-min) (point-max))) (defun my-spookfox-send-line () (interactive) (my-spookfox-send-region (line-beginning-position) (line-end-position))) (defun my-spookfox-send-last-expression () (interactive) (my-spookfox-send-region (save-excursion (nodejs-repl--beginning-of-expression)) (point))) (defvar-keymap my-js-spookfox-minor-mode-map :doc "Send parts of the buffer to Spookfox." "C-x C-e" 'my-spookfox-send-last-expression "C-c C-j" 'my-spookfox-send-line "C-c C-r" 'my-spookfox-send-region "C-c C-c" 'my-spookfox-send-buffer) (define-minor-mode my-js-spookfox-minor-mode "Send code to Spookfox.")
I usually edit Javascript files with js2-mode, so I can use my-js-spookfox-minor-mode
in addition to that.
I can turn the minor mode on automatically for :spookfox t
source blocks. There's no org-babel-edit-prep:js
yet, I think, so we need to define it instead of advising it.
(defun org-babel-edit-prep:js (info) (when (assq :spookfox (nth 2 info)) (my-js-spookfox-minor-mode 1)))
Let's try it out by sending the last line repeatedly:
I used to do this kind of interaction with Skewer, which also has some extra stuff for evaluating CSS and HTML. Skewer hasn't been updated in a while, but maybe I should also check that out again to see if I can get it working.
Anyway, now it's just a little bit easier to tinker with Javascript!
This little snippet makes it easy to copy text for
pasting. It defines a custom Org link that starts
with copy:
. When I follow the link by clicking
on it or using C-c C-o
(org-open-at-point
), it
copies the text to the kill ring (which is what
Emacs calls the clipboard) so that I can paste it
anywhere. For example, [[copy:Hello world]]
becomes a link to copy "Hello world". Copying
means never having to worry about typos or
accidentally selecting only part of the text.
(use-package org :config (org-link-set-parameters "copy" :follow (lambda (link) (kill-new link)) :export (lambda (_ desc &rest _) desc)))
I can use these links as part of my checklist so
that I can quickly fill in things like my business
name and other details. I can put sensitive
information like my social insurance number in a
GPG-encrypted file. (Just set up your GPG keys and
end a filename with .gpg
, and Emacs will take
care of transparently encrypting and decrypting
the file.)
I can also export those links as part of my Org Babel output. For example, the following code calculates the numbers I need to fill in a T5 form for the other-than-eligible dividends that I issue myself according to the T5 instructions from the CRA.
(let* ((box-10 1234) ; fake number for demo (box-11 (* 1.15 box-10)) (box-12 (* 0.090301 box-11))) `((box-10 ,(format "[[copy:%.2f][%.2f]]" box-10 box-10)) (box-11 ,(format "[[copy:%.2f][%.2f]]" box-11 box-11)) (box-12 ,(format "[[copy:%.2f][%.2f]]" box-12 box-12))))
box-10 | 1234.00 |
box-11 | 1419.10 |
box-12 | 128.15 |
On my computer, the numbers become links that I can click and copy. Another little shortcut thanks to Emacs and Org Mode!
tldr (2167 words): I can make animating presentation maps easier by writing my own functions for the Emacs text editor. In this post, I show how I can animate an SVG element by element. I can also add IDs to the path and use CSS to build up an SVG with temporary highlighting in a Reveal.js presentation.
Convert PDF to SVG with Inkscape (Cairo option) or pdftocairo)
Animation styles
Ideas for next steps:
I often have a hard time organizing my thoughts into a linear sequence. Sketches are nice because they let me jump around and still show the connections between ideas. For presentations, I'd like to walk people through these sketches by highlighting different areas. For example, I might highlight the current topic or show the previous topics that are connected to the current one. Of course, this is something Emacs can help with. Before we dive into it, here are quick previews of the kinds of animation I'm talking about:
Let's start with getting the sketches. I usually export my sketches as PNGs from my Supernote A5X. But if I know that I'm going to animate a sketch, I can export it as a PDF. I've recently been experimenting with Adobe Fresco on the iPad, which can also export to PDF. The PDF I get from Fresco is easier to animate, but I prefer to draw on the Supernote because it's an e-ink device (and because the kiddo usually uses the iPad).
If I start with a PNG, I could use Inkscape to trace the PNG and turn it into an SVG. I think Inkscape uses autotrace behind the scenes. I don't usually put my highlights on a separate layer, so autotrace will make odd shapes.
It's a lot easier if you start off with vector graphics in the first place. I can export a vector PDF from the SuperNote A5X and either import it into Inkscape using the Cairo option or use the command-line pdftocairo tool.
I've been looking into using Adobe Fresco, which is a free app available for the iPad. Fresco's PDF export can be converted to an SVG using Inkscape or PDF to Cairo. What I like about the output of this app is that it gives me individual elements as their own paths and they're listed in order of drawing. This makes it really easy to animate by just going through the paths in order.
Here's a sample SVG file that pdfcairo creates from an Adobe Fresco PDF export:
pdftocairo -svg ~/Downloads/subed-audio.pdf ~/Downloads/subed-audio.svg
Adobe Fresco also includes built-in time-lapse, but since I often like to move things around or tidy things up, it's easier to just work with the final image, export it as a PDF, and convert it to an SVG.
I can make a very simple animation by setting the opacity of all the paths to 0, then looping through the elements to set the opacity back to 1 and write that version of the SVG to a separate file. From how-can-i-generate-png-frames-that-step-through-the-highlights:
(defun my-animate-svg-paths (filename output-dir) "Add one path at a time. Save the resulting SVGs to OUTPUT-DIR." (unless (file-directory-p output-dir) (make-directory output-dir t)) (let* ((dom (xml-parse-file filename)) (paths (seq-filter (lambda (e) (dom-attr e 'style)) (dom-by-tag dom 'path))) (total (length paths)) (frame-num (length paths)) result) (dolist (elem paths) (dom-set-attribute elem 'style (concat (dom-attr elem 'style) ";mix-blend-mode:darken"))) (with-temp-file (expand-file-name (format "frame-%03d.svg" (1+ frame-num)) output-dir) (xml-print dom)) (dolist (elem paths) (dom-set-attribute elem 'style (concat (dom-attr elem 'style) ";fill-opacity:0"))) (dolist (elem paths) (with-temp-file (expand-file-name (format "frame-%03d.svg" (- total frame-num)) output-dir) (message "%03d" frame-num) (dom-set-attribute elem 'style (concat (dom-attr elem 'style) ";fill-opacity:1")) (push (list (format "frame-%03d.svg" (1+ (- total frame-num))) (dom-attr elem 'id)) result) (setq frame-num (1- frame-num)) (xml-print dom))) (reverse result)))
Here's how I call it:
(my-animate-svg-paths "~/Downloads/subed-audio.svg" "/tmp/subed-audio/frames" t)
Then I can use FFmpeg to combine all of those frames into a video:
ffmpeg -i frame-%03d.svg -vf palettegen -y palette.png
ffmpeg -framerate 30 -i frame-%03d.svg -i palette.png -lavfi "paletteuse" -loop 0 -y animation-loop.gif
Neither Supernote nor Adobe Fresco give me the original stroke information. These are filled shapes, so I can't animate something drawing it. But having different elements appear in sequence is fine for my purposes. If you happen to know how to get stroke information out of Supernote .note files or of an iPad app that exports nice single-line SVGs that have stroke direction, I would love to hear about it.
When I export a PDF from Supernote and convert it to an SVG, each color is a combined shape with all the elements. If I want to animate parts of the image, I have to break it up and recombine selected elements (Inkscape's Ctrl-k shortcut) so that the holes in shapes are properly handled. This is a bit of a tedious process and it usually ends up with elements in a pretty random order. Since I have to reorder elements by hand, I don't really want to animate the sketch letter-by-letter. Instead, I combine them into larger chunks like topics or paragraphs.
The following code takes the PDF, converts it to an SVG, recolours highlights, and then breaks up paths into elements:
(defun my-sketch-convert-pdf-and-break-up-paths (pdf-file &optional rotate) "Convert PDF to SVG and break up paths." (interactive (list (read-file-name (format "PDF (%s): " (my-latest-file "~/Dropbox/Supernote/EXPORT/" "pdf")) "~/Dropbox/Supernote/EXPORT/" (my-latest-file "~/Dropbox/Supernote/EXPORT/" "pdf") t nil (lambda (s) (string-match "pdf" s))))) (unless (file-exists-p (concat (file-name-sans-extension pdf-file) ".svg")) (call-process "pdftocairo" nil nil nil "-svg" (expand-file-name pdf-file) (expand-file-name (concat (file-name-sans-extension pdf-file) ".svg")))) (let ((dom (xml-parse-file (expand-file-name (concat (file-name-sans-extension pdf-file) ".svg")))) highlights) (setq highlights (dom-node 'g '((id . "highlights")))) (dom-append-child dom highlights) (dolist (path (dom-by-tag dom 'path)) ;; recolor and move (unless (string-match (regexp-quote "rgb(0%,0%,0%)") (or (dom-attr path 'style) "")) (dom-remove-node dom path) (dom-append-child highlights path) (dom-set-attribute path 'style (replace-regexp-in-string (regexp-quote "rgb(78.822327%,78.822327%,78.822327%)") "#f6f396" (or (dom-attr path 'style) "")))) (let ((parent (dom-parent dom path))) ;; break apart (when (dom-attr path 'd) (dolist (part (split-string (dom-attr path 'd) "M " t " +")) (dom-append-child parent (dom-node 'path `((style . ,(dom-attr path 'style)) (d . ,(concat "M " part)))))) (dom-remove-node dom path)))) ;; remove the use (dolist (use (dom-by-tag dom 'use)) (dom-remove-node dom use)) (dolist (use (dom-by-tag dom 'image)) (dom-remove-node dom use)) ;; move the first g down (let ((g (car (dom-by-id dom "surface1")))) (setf (cddar dom) (seq-remove (lambda (o) (and (listp o) (string= (dom-attr o 'id) "surface1"))) (dom-children dom))) (dom-append-child dom g) (when rotate (let* ((old-width (dom-attr dom 'width)) (old-height (dom-attr dom 'height)) (view-box (mapcar 'string-to-number (split-string (dom-attr dom 'viewBox)))) (rotate (format "rotate(90) translate(0 %s)" (- (elt view-box 3))))) (dom-set-attribute dom 'width old-height) (dom-set-attribute dom 'height old-width) (dom-set-attribute dom 'viewBox (format "0 0 %d %d" (elt view-box 3) (elt view-box 2))) (dom-set-attribute highlights 'transform rotate) (dom-set-attribute g 'transform rotate)))) (with-temp-file (expand-file-name (concat (file-name-sans-extension pdf-file) "-split.svg")) (svg-print (car dom)))))
You can see how the spaces inside letters like "o" end up being black. Selecting and combining those paths fixes that.
If there were shapes that were touching, then I need to draw lines and fracture the shapes in order to break them apart.
The end result should be an SVG with the different chunks that I might want to animate, but I need to identify the paths first. You can assign object IDs in Inkscape, but this is a bit of an annoying process since I haven't figured out a keyboard-friendly way to set object IDs. I usually find it easier to just set up an Autokey shortcut (or AutoHotkey in Windows) to click on the ID text box so that I can type something in.
import time x, y = mouse.get_location() # Use the coordinates of the ID text field on your screen; xev can help mouse.click_absolute(3152, 639, 1) time.sleep(1) keyboard.send_keys("<ctrl>+a") mouse.move_cursor(x, y)
Then I can select each element, press the shortcut key, and type an ID into the textbox. I might use "t-…" to indicate the text for a map section, "h-…" to indicate a highlight, and arrows by specifying their start and end.
To simplify things, I wrote a function in Emacs that will go through the different groups that I've made, show each path in a different color and with a reasonable guess at a bounding box, and prompt me for an ID. This way, I can quickly assign IDs to all of the paths. The completion is mostly there to make sure I don't accidentally reuse an ID, although it can try to combine paths if I specify the ID. It saves the paths after each change so that I can start and stop as needed. Identifying paths in Emacs is usually much nicer than identifying them in Inkscape.
(defun my-svg-identify-paths (filename) "Prompt for IDs for each path in FILENAME." (interactive (list (read-file-name "SVG: " nil nil (lambda (f) (string-match "\\.svg$" f))))) (let* ((dom (car (xml-parse-file filename))) (paths (dom-by-tag dom 'path)) (vertico-count 3) (ids (seq-keep (lambda (path) (unless (string-match "path[0-9]+" (or (dom-attr path 'id) "path0")) (dom-attr path 'id))) paths)) (edges (window-inside-pixel-edges (get-buffer-window))) id) (my-svg-display "*image*" dom nil t) (dolist (path paths) (when (string-match "path[0-9]+" (or (dom-attr path 'id) "path0")) ;; display the image with an outline (unwind-protect (progn (my-svg-display "*image*" dom (dom-attr path 'id) t) (setq id (completing-read (format "ID (%s): " (dom-attr path 'id)) ids)) ;; already exists, merge with existing element (if-let ((old (dom-by-id dom id))) (progn (dom-set-attribute old 'd (concat (dom-attr (dom-by-id dom id) 'd) " " ;; change relative to absolute (replace-regexp-in-string "^m" "M" (dom-attr path 'd)))) (dom-remove-node dom path) (setq id nil)) (dom-set-attribute path 'id id) (add-to-list 'ids id)))) ;; save the image just in case we get interrupted halfway through (with-temp-file filename (svg-print dom))))))
Then I can animate SVGs by specifying the IDs. I can reorder the paths in the SVG itself so that I can animate it group by group, like the way that the Adobe Fresco SVGs were animated element by element.
(my-svg-reorder-paths "~/proj/2023-12-audio-workflow/map.svg" '("t-start" "h-audio" "h-capture" "t-but" "t-mic" "h-mic" "t-reviewing" "h-reviewing" "t-words" "h-words" "t-workflow" "h-workflow" "t-lapel" "h-lapel" "mic-recorder" "t-recorder" "h-recorder" "t-syncthing" "h-sync" "t-keywords" "h-keywords" "t-keyword-types" "t-lines" "h-lines" "t-align" "h-align" "arrow" "t-org" "h-org" "t-todo" "h-todo" "h-linked" "t-jump" "h-jump" "t-waveform" "h-waveform" "t-someday" "h-sections" "t-speech-recognition" "h-speech-recognition" "t-ai" "h-ai" "t-summary" "extra") "~/proj/2023-12-audio-workflow/map-output.svg") (my-animate-svg-paths "~/proj/2023-12-audio-workflow/map-output.svg" "~/proj/2023-12-audio-workflow/frames/")
frame-001.svg | t-start |
frame-002.svg | h-audio |
frame-003.svg | h-capture |
frame-004.svg | t-but |
frame-005.svg | t-mic |
frame-006.svg | h-mic |
frame-007.svg | t-reviewing |
frame-008.svg | h-reviewing |
frame-009.svg | t-words |
frame-010.svg | h-words |
frame-011.svg | t-workflow |
frame-012.svg | h-workflow |
frame-013.svg | t-lapel |
frame-014.svg | h-lapel |
frame-015.svg | mic-recorder |
frame-016.svg | t-recorder |
frame-017.svg | h-recorder |
frame-018.svg | t-syncthing |
frame-019.svg | h-sync |
frame-020.svg | t-keywords |
frame-021.svg | h-keywords |
frame-022.svg | t-keyword-types |
frame-023.svg | t-lines |
frame-024.svg | h-lines |
frame-025.svg | t-align |
frame-026.svg | h-align |
frame-027.svg | arrow |
frame-028.svg | t-org |
frame-029.svg | h-org |
frame-030.svg | t-todo |
frame-031.svg | h-todo |
frame-032.svg | h-linked |
frame-033.svg | t-jump |
frame-034.svg | h-jump |
frame-035.svg | t-waveform |
frame-036.svg | h-waveform |
frame-037.svg | t-someday |
frame-038.svg | h-sections |
frame-039.svg | t-speech-recognition |
frame-040.svg | h-speech-recognition |
frame-041.svg | t-ai |
frame-042.svg | h-ai |
frame-043.svg | t-summary |
frame-044.svg | extra |
The table of filenames makes it easy to use specific frames as part of a presentation or video.
Here is the result as a video:
(let ((compile-media-output-video-width 1280) (compile-media-output-video-height 720)) (my-ffmpeg-animate-images (directory-files "~/proj/2023-12-audio-workflow/frames/" t "\\.svg$") (expand-file-name "~/proj/2023-12-audio-workflow/frames/animation.webm") 4))
The way it works is that the my-svg-reorder-paths
function removes
and readds elements following the list of IDs specified, so
everything's ready to go for step-by-step animation. Here's the code:
(defun my-svg-reorder-paths (filename &optional ids output-filename) "Sort paths in FILENAME." (interactive (list (read-file-name "SVG: " nil nil (lambda (f) (string-match "\\.svg$" f))) nil (read-file-name "Output: "))) (let* ((dom (car (xml-parse-file filename))) (paths (dom-by-tag dom 'path)) (parent (dom-parent dom (car paths))) (ids-left (nreverse (seq-keep (lambda (path) (unless (string-match "path[0-9]+" (or (dom-attr path 'id) "path0")) (dom-attr path 'id))) paths))) list) (when (called-interactively-p) (while ids-left (my-svg-display "*image*" dom (car ids-left)) (let ((current (completing-read (format "ID (%s): " (car ids-left)) ids-left nil nil nil nil (car ids-left))) node) (add-to-list 'ids current) (setq ids-left (seq-remove (lambda (o) (string= o current)) ids-left))))) (if ids ;; reorganize under the first path's parent (progn (dolist (id ids) (if-let ((node (car (dom-by-id dom id)))) (progn (dom-remove-node dom node) (dom-append-child parent node)) (message "Could not find %s" id))) (with-temp-file (or output-filename filename) (svg-print dom)))) (nreverse (seq-keep (lambda (path) (unless (string-match "path[0-9]+" (or (dom-attr path 'id) "path0")) (dom-attr path 'id))) (dom-by-tag dom 'path)))))
I can also use CSS rules to transition between opacity values for more complex animations. For my EmacsConf 2023 presentation, I wanted to make a self-paced, narrated presentation so that people could follow hyperlinks, read the source code, and explore. I wanted to include a map so that I could try to make sense of everything. For this map, I wanted to highlight the previous sections that were connected to the topic for the current section.
I used a custom Org link to include the full contents of the SVG instead of just including it with an img tag.
my-include:~/proj/emacsconf-2023-emacsconf/map.svg?wrap=export html
(defun my-include-export (path _ format _) "Export PATH to FORMAT using the specified wrap parameter." (let (params body start end) (when (string-match "^\\(.*+?\\)\\(?:::\\|\\?\\)\\(.*+\\)" path) (setq params (save-match-data (org-protocol-convert-query-to-plist (match-string 2 path))) path (match-string 1 path))) (with-temp-buffer (insert-file-contents-literally path) (when (string-match "\\.org$" path) (org-mode)) (if (plist-get params :name) (when (org-babel-find-named-block (plist-get params :name)) (goto-char (org-babel-find-named-block (plist-get params :name))) (let ((block (org-element-context))) (setq start (org-element-begin block) end (org-element-end block)))) (goto-char (point-min)) (when (plist-get params :from-regexp) (re-search-forward (url-unhex-string (plist-get params :from-regexp))) (goto-char (match-beginning 0))) (setq start (point)) (setq end (point-max)) (when (plist-get params :to-regexp) (re-search-forward (url-unhex-string (plist-get params :to-regexp))) (setq end (match-beginning 0)))) (setq body (buffer-substring start end))) (with-temp-buffer (when (plist-get params :wrap) (let* ((wrap (plist-get params :wrap)) block args) (when (string-match "\\<\\(\\S-+\\)\\( +.*\\)?" wrap) (setq block (match-string 1 wrap)) (setq args (match-string 2 wrap)) (setq body (format "#+BEGIN_%s%s\n%s\n#+END_%s\n" block (or args "") body block))))) (when (plist-get params :summary) (setq body (format "#+begin_my_details %s\n%s\n#+end_my_details\n" (plist-get params :summary) body))) (insert body) (org-export-as format nil nil t))))
I wanted to be able to specify the entire sequence using a table in
the Org Mode source for my presentation. Each row had the slide ID, a
list of highlights in the form prev1,prev2;current
, and a
comma-separated list of elements to add to the full-opacity view.
Slide | Highlight | Additional elements |
---|---|---|
props-map | h-email;h-properties | t-email,email-properties,t-properties |
file-prefixes | h-properties;h-filename | t-filename,properties-filename |
renaming | h-filename;h-renaming | t-renaming,filename-renaming |
shell-scripts | h-renaming;h-shell-scripts | renaming-shell-scripts,t-shell-scripts |
availability | h-properties;h-timezone | t-timezone,properties-timezone |
schedule | h-timezone;h-schedule | t-schedule,timezone-schedule |
emailing-speakers | h-timezone,h-mail-merge;h-emailing-speakers | schedule-emailing-speakers,t-emailing-speakers |
template | h-properties;h-template | t-template,properties-template |
wiki | h-template;h-wiki | t-wiki,template-wiki,schedule-wiki |
pad | h-template;h-pad | template-pad,t-pad |
mail-merge | h-template;h-mail-merge | t-mail-merge,template-mail-merge,schedule-mail-merge,emailing-speakers-mail-merge |
bbb | h-bbb | t-bbb |
checkin | h-mail-merge;h-checkin | t-checkin,bbb-checkin |
redirect | h-bbb;h-redirect | t-redirect,bbb-redirect |
shortcuts | h-email;h-shortcuts | t-shortcuts,email-shortcuts |
logbook | h-shortcuts;h-logbook | shortcuts-logbook,t-logbook |
captions | h-captions | t-captions,captions-wiki |
tramp | h-captions;h-tramp | t-tramp,captions-tramp |
crontab | h-tramp;h-crontab | tramp-crontab,bbb-crontab,t-crontab |
transitions | h-crontab;h-transitions | shell-scripts-transitions,t-transitions,shortcuts-transitions,transitions-crontab |
irc | h-transitions;h-irc | t-irc,transitions-irc |
Reveal.js adds a "current" class to the slide, so I can use that as a trigger for the transition. I have a bit of Emacs Lisp code that generates some very messy CSS, in which I specify the ID of the slide, followed by all of the elements that need their opacity set to 1, and also specifying the highlights that will be shown in an animated way.
(defun my-reveal-svg-progression-css (map-progression &optional highlight-duration) "Make the CSS. map-progression should be a list of lists with the following format: ((\"slide-id\" \"prev1,prev2;cur1\" \"id-to-add1,id-to-add2\") ...)." (setq highlight-duration (or highlight-duration 2)) (let (full) (format "<style>%s</style>" (mapconcat (lambda (slide) (setq full (append (split-string (elt slide 2) ",") full)) (format "#slide-%s.present path { opacity: 0.2 } %s { opacity: 1 !important } %s" (car slide) (mapconcat (lambda (id) (format "#slide-%s.present #%s" (car slide) id)) full ", ") (my-reveal-svg-highlight-different-colors slide))) map-progression "\n"))))
#+begin_src emacs-lisp :exports code :var map-progression=progression :var highlight-duration=2 :results silent (my-reveal-svg-progression-css map-progression highlight-duration) #+end_src
Here's an excerpt showing the kind of code it makes:
<style>#slide-props-map.present path { opacity: 0.2 } #slide-props-map.present #t-email, #slide-props-map.present #email-properties, #slide-props-map.present #t-properties { opacity: 1 !important } #slide-props-map.present #h-email { fill: #c6c6c6; opacity: 1 !important; transition: fill 0.5s; transition-delay: 0.0s }#slide-props-map.present #h-properties { fill: #f6f396; opacity: 1 !important; transition: fill 0.5s; transition-delay: 0.5s } #slide-file-prefixes.present path { opacity: 0.2 } #slide-file-prefixes.present #t-filename, #slide-file-prefixes.present #properties-filename, #slide-file-prefixes.present #t-email, #slide-file-prefixes.present #email-properties, #slide-file-prefixes.present #t-properties { opacity: 1 !important } #slide-file-prefixes.present #h-properties { fill: #c6c6c6; opacity: 1 !important; transition: fill 0.5s; transition-delay: 0.0s }#slide-file-prefixes.present #h-filename { fill: #f6f396; opacity: 1 !important; transition: fill 0.5s; transition-delay: 0.5s } ...</style>
Since it's automatically generated, I don't have to worry about it once I've gotten it to work. It's all hidden in a results drawer. So this CSS highlights specific parts of the SVG with a transition, and the highlight changes over the course of a second or two. It highlights the previous names and then the current one. The topics I'd already discussed would be in black, and the topics that I had yet to discuss would be in very light gray. This could give people a sense of the progress through the presentation.
(defun my-reveal-svg-animation (slide) (string-join (seq-map-indexed (lambda (step-ids i) (format "%s { fill: #f6f396; transition: fill %ds; transition-delay: %ds }" (mapconcat (lambda (id) (format "#slide-%s.present #%s" (car slide) id)) (split-string step-ids ",") ", ") highlight-duration (* i highlight-duration))) (split-string (elt slide 1) ";")) "\n")) (defun my-reveal-svg-highlight-different-colors (slide) (let* ((colors '("#f6f396" "#c6c6c6")) ; reverse (steps (split-string (elt slide 1) ";")) (step-length 0.5)) (string-join (seq-map-indexed (lambda (step-ids i) (format "%s { fill: %s; opacity: 1 !important; transition: fill %.1fs; transition-delay: %.1fs }" (mapconcat (lambda (id) (format "#slide-%s.present #%s" (car slide) id)) (split-string step-ids ",") ", ") (elt colors (- (length steps) i 1)) step-length (* i 0.5))) steps)))) (defun my-reveal-svg-progression-css (map-progression &optional highlight-duration) "Make the CSS. map-progression should be a list of lists with the following format: ((\"slide-id\" \"prev1,prev2;cur1\" \"id-to-add1,id-to-add2\") ...)." (setq highlight-duration (or highlight-duration 2)) (let (full) (format "<style>%s</style>" (mapconcat (lambda (slide) (setq full (append (split-string (elt slide 2) ",") full)) (format "#slide-%s.present path { opacity: 0.2 } %s { opacity: 1 !important } %s" (car slide) (mapconcat (lambda (id) (format "#slide-%s.present #%s" (car slide) id)) full ", ") (my-reveal-svg-highlight-different-colors slide))) map-progression "\n"))))
As a result, as I go through my presentation, the image appears to build up incrementally, which is the effect that I was going for. I can test this by exporting only my map slides:
(save-excursion (goto-char (org-babel-find-named-block "progression-css")) (org-babel-execute-src-block)) (let ((org-tags-exclude-from-inheritance "map") (org-export-select-tags '("map"))) (oer-reveal-export-to-html))
path
. Then I'll be able to make
diagrams even more easily.("frame-001.svg" "h-foo" opacity 1)
). Then I could write frames to
SVGs.Anyway, those are some workflows for animating sketches with Inkscape and Emacs. Yay Emacs!
: Added some code to display the QR code on the right side.
John Kitchin includes little QR codes in his videos. I
thought that was a neat touch that makes it easier for
people to jump to a link while they're watching. I'd like to
make it easier to show QR codes too. The following code lets
me show a QR code for the Org link at point. Since many of
my links use custom Org link types that aren't that useful
for people to scan, the code reuses the link resolution code
from https://sachachua.com/dotemacs#web-link so that I can get the regular
https:
link.
(defun my-org-link-qr (url) "Display a QR code for URL in a buffer." (let ((buf (save-window-excursion (qrencode--encode-to-buffer (my-org-stored-link-as-url url))))) (display-buffer-in-side-window buf '((side . right))))) (use-package qrencode :config (with-eval-after-load 'embark (define-key embark-org-link-map (kbd "q") #'my-org-link-qr)))
embark-around-action-hooks
consult--grep-position
Summary (882 words): Emacs macros make it easy to define sets of related functions for custom Org links. This makes it easier to link to projects and export or copy the links to the files in the web-based repos. You can also use that information to consult-ripgrep across lots of projects.
I'd like to get better at writing notes while coding and at turning
those notes into blog posts and videos. I want to be able to link to
files in projects easily with the ability to complete, follow, and
export links. For example, [[subed:subed.el]]
should become
subed.el, which opens the file if I'm in Emacs and exports a
link if I'm publishing a post. I've been making custom link types
using org-link-set-parameters
. I think it's time to make a macro
that defines that set of functions for me. Emacs Lisp macros are a
great way to write code to write code.
(defvar my-project-web-base-list nil "Local path . web repo URLs for easy linking.") (defmacro my-org-project-link (type file-path git-url) `(progn (defun ,(intern (format "my-org-%s-complete" type)) () ,(format "Complete a file from %s." type) (concat ,type ":" (completing-read "File: " (projectile-project-files ,file-path)))) (defun ,(intern (format "my-org-%s-follow" type)) (link _) ,(format "Open a file from %s." type) (find-file (expand-file-name link ,file-path))) (defun ,(intern (format "my-org-%s-export" type)) (link desc format _) "Export link to file." (setq desc (or desc link)) (when ,git-url (setq link (concat ,git-url (replace-regexp-in-string "^/" "" link)))) (pcase format ((or 'html '11ty) (format "<a href=\"%s\">%s</a>" link (or desc link))) ('md (if desc (format "[%s](%s)" desc link) (format "<%s>" link))) ('latex (format "\\href{%s}{%s}" link desc)) ('texinfo (format "@uref{%s,%s}" link desc)) ('ascii (format "%s (%s)" desc link)) (_ (format "%s (%s)" desc link)))) (with-eval-after-load 'org (org-link-set-parameters ,type :complete (quote ,(intern (format "my-org-%s-complete" type))) :export (quote ,(intern (format "my-org-%s-export" type))) :follow (quote ,(intern (format "my-org-%s-follow" type)))) (cl-pushnew (cons (expand-file-name ,file-path) ,git-url) my-project-web-base-list :test 'equal))))
Then I can define projects this way:
(my-org-project-link "subed" "~/proj/subed/subed/" "https://github.com/sachac/subed/blob/main/subed/" ;; "https://codeberg.org/sachac/subed/src/branch/main/subed/" ) (my-org-project-link "emacsconf-el" "~/proj/emacsconf/lisp/" "https://git.emacsconf.org/emacsconf-el/tree/") (my-org-project-link "subed-record" "~/proj/subed-record/" "https://github.com/sachac/subed-record/blob/main/" ;; "https://codeberg.org/sachac/subed-record/src/branch/main/" ) (my-org-project-link "compile-media" "~/proj/compile-media/" "https://github.com/sachac/compile-media/blob/main/" ;; "https://codeberg.org/sachac/compile-media/src/branch/main/" ) (my-org-project-link "ox-11ty" "~/proj/ox-11ty/" "https://github.com/sachac/ox-11ty/blob/master/")
And I can complete them with the usual C-c C-l
(org-insert-link
) process:
Sketches are handled by my Org Mode sketch links, but we can add them anyway.
(cl-pushnew (cons (expand-file-name "~/sync/sketches/") "https://sketches.sachachua.com/filename/") my-project-web-base-list :test 'equal)
I've been really liking being able to refer to various emacsconf-el files by just selecting the link type and completing the filename, so maybe it'll be easier to write about lots of other stuff if I extend that to my other projects.
: Add Wayback machine.
Keeping a list of projects and their web versions also makes it easier
for me to get the URL for something. I try to post as much as possible
on the Web so that it's easier for me to find things again and so that
other people can pick up ideas from my notes. Things are a bit
scattered: my blog, repositories on Github and Codeberg, my
sketches… I don't want to think about where the code has ended
up, I just want to grab the URL. If I'm going to put the link into an
Org Mode document, that's super easy. I just take advantage of the
things I've added to org-store-link
. If I'm going to put it into an
e-mail or a toot or wherever else, I just want the bare URL.
I can think of two ways to approach this. One is a command that copies just the URL by figuring it out from the buffer filename, which allows me to special-case a bunch of things:
(defun my-copy-link (&optional filename skip-links) "Return the URL of this file. If FILENAME is non-nil, use that instead. If SKIP-LINKS is non-nil, skip custom links. If we're in a Dired buffer, use the file at point." (interactive) (setq filename (or filename (if (derived-mode-p 'dired-mode) (dired-get-filename)) (buffer-file-name))) (if-let* ((project-re (concat "\\(" (regexp-opt (mapcar 'car my-project-web-base-list)) "\\)" "\\(.*\\)")) (url (cond ((and (derived-mode-p 'org-mode) (eq (org-element-type (org-element-context)) 'link) (not skip-links)) (pcase (org-element-property :type (org-element-context)) ((or "https" "http") (org-element-property :raw-link (org-element-context))) ("yt" (org-element-property :path (org-element-context))) ;; if it's a custom link, visit it and get the link (_ (save-window-excursion (org-open-at-point) (my-copy-link nil t))))) ;; links to my config usually have a CUSTOM_ID property ((string= (buffer-file-name) (expand-file-name "~/sync/emacs/Sacha.org")) (concat "https://sachachua.com/dotemacs#" (org-entry-get-with-inheritance "CUSTOM_ID"))) ;; blog post drafts have permalinks ((and (derived-mode-p 'org-mode) (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK")) (concat "https://sachachua.com" (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK"))) ;; some projects have web repos ((string-match project-re filename) (concat (assoc-default (match-string 1 filename) my-project-web-base-list) (url-hexify-string (match-string 2 filename))))))) (progn (when (called-interactively-p 'any) (kill-new url) (message "%s" url)) url) (error "Couldn't figure out URL.")))
Another approach is to hitch a ride on the Org Mode link storage and
export functions and just grab the URL from whatever link I've stored
with org-store-link
, which I've bound to C-c l
. I almost always
have an HTML version of the exported link. We can even use XML parsing
instead of regular expressions.
(defun my-org-link-as-url (link) "Return the final URL for LINK." (dom-attr (dom-by-tag (with-temp-buffer (insert (org-export-string-as link 'html t)) (xml-parse-region (point-min) (point-max))) 'a) 'href)) (defun my-org-stored-link-as-url (&optional link insert) "Copy the stored link as a plain URL. If LINK is specified, use that instead." (interactive (list nil current-prefix-arg)) (setq link (or link (caar org-stored-links))) (let ((url (if link (my-org-link-as-url link) (error "No stored link")))) (when (called-interactively-p 'any) (if url (if insert (insert url) (kill-new url)) (error "Could not find URL."))) url)) (ert-deftest my-org-stored-link-as-url () (should (string= (my-org-stored-link-as-url "[[dotemacs:web-link]]") "https://sachachua.com/dotemacs#web-link")) (should (string= (my-org-stored-link-as-url "[[dotemacs:org-mode-sketch-links][my Org Mode sketch links]]") "https://sachachua.com/dotemacs#org-mode-sketch-links"))) (defun my-embark-org-copy-exported-url-as-wayback (link) (interactive "MLink: ") (my-embark-org-copy-exported-url link t)) (defun my-embark-org-copy-exported-url (link &optional wayback) (interactive "MLink: \np") (let ((url (my-org-link-as-url link))) (when (and (derived-mode-p 'org-mode) (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK") (string-match "^/" url)) ;; local file links are copied to blog directories (setq url (concat "https://sachachua.com" (org-entry-get-with-inheritance "EXPORT_ELEVENTY_PERMALINK") (replace-regexp-in-string "[\\?&].*" "" (file-name-nondirectory link))))) (when (and wayback (not (string-match (regexp-quote "^https://web.archive.org") url))) (setq url (concat "https://web.archive.org/web/" (format-time-string "%Y%m%d%H%M%S/") url))) (kill-new url) (message "Copied %s" url))) (with-eval-after-load 'embark-org (define-key embark-org-link-map "u" #'my-embark-org-copy-exported-url) (define-key embark-org-link-map "U" #'my-embark-org-copy-exported-url-as-wayback) (define-key embark-org-link-copy-map "u" #'my-embark-org-copy-exported-url) (define-key embark-org-link-copy-map "U" #'my-embark-org-copy-exported-url-as-wayback))
We'll see which one I end up using. I think both approaches might come in handy.
Since my-project-web-base-list
is a list of projects I often think
about or write about, I can also make something that searches through
them. That way, I don't have to care about where my code is.
(defun my-consult-ripgrep-code () (interactive) (consult-ripgrep (mapcar 'car my-project-web-base-list)))
I can add .rgignore
files in directories to tell ripgrep to ignore
things like node_modules
or *.json
.
I also want to search my Emacs configuration at the same time, although links to my config are handled by my dotemacs link type so I'll leave the URL as nil. This is also the way I can handle other unpublished directories.
(cl-pushnew (cons (expand-file-name "~/sync/emacs/Sacha.org") nil) my-project-web-base-list :test 'equal) (cl-pushnew (cons (expand-file-name "~/proj/static-blog/_includes") nil) my-project-web-base-list :test 'equal) (cl-pushnew (cons (expand-file-name "~/bin") nil) my-project-web-base-list :test 'equal)
Actually, let's throw my blog posts and Org files in there as well, since I often have code snippets. If it gets to be too much, I can always have different commands search different things.
(cl-pushnew (cons (expand-file-name "~/proj/static-blog/blog/") "https://sachachua.com/blog/") my-project-web-base-list :test 'equal) (cl-pushnew (cons (expand-file-name "~/sync/orgzly") nil) my-project-web-base-list :test 'equal)
I don't have anything bound to M-s c
(code) yet, so let's try that.
(keymap-global-set "M-s c" #'my-consult-ripgrep-code)
At some point, it might be fun to get Embark set up so that I can grab a link to something right from the consult-ripgrep interface. In the meantime, I can always jump to it and get the link.
consult-ripgrep
, since consult-ripgrep
gives me consult-grep
targets instead of consult-location
:
(cl-defun embark-consult--at-location (&rest args &key target type run &allow-other-keys) "RUN action at the target location." (save-window-excursion (save-excursion (save-restriction (pcase type ('consult-location (consult--jump (consult--get-location target))) ('org-heading (org-goto-marker-or-bmk (get-text-property 0 'org-marker target))) ('consult-grep (consult--jump (consult--grep-position target))) ('file (find-file target))) (apply run args))))) (cl-pushnew #'embark-consult--at-location (alist-get 'org-store-link embark-around-action-hooks))
I think I can use it with M-s c
to search for the code, then C-.
C-c l
on the matching line, where C-c l
is my regular keybinding
for storing links. Thanks, Omar!
In general, I don't want to have to think about where something is on my laptop or where it's published on the Web, I just want to
I usually use C-u C-c C-w
(org-refile
with a prefix argument),
counting on consult + orderless to let me just put in keywords in any
order. This doesn't let me search the body, though.
org-ql seems like a great fit for this. It's fast and flexible, and might be useful for all sorts of queries.
I think by default org-ql matches against all of the text in the
entry. You can scope the match to just the heading with a query like
heading:your,text
. I wanted to see all matches, prioritize heading
matches so that they come first. I thought about saving the query by
adding advice before org-ql-search
and then adding a new comparator
function, but that got a bit complicated, so I haven't figured that
out yet. It was easier to figure out how to rewrite the query to use
heading
instead of rifle
, do the more constrained query, and then
append the other matches that weren't in the heading matches.
Also, I wanted something a little like helm-org-rifle
's live
previews. I've used helm before, but I was curious about getting it to
work with consult.
Here's a quick demo of my-consult-org-ql-agenda-jump
, which I've
bound to M-s a
. The top few tasks have org-ql in the heading, and
they're followed by the rest of the matches. I think this might be handy.
(defun my-consult-org-ql-agenda-jump () "Search agenda files with preview." (interactive) (let* ((marker (consult--read (consult--dynamic-collection #'my-consult-org-ql-agenda-match) :state (consult--jump-state) :category 'consult-org-heading :prompt "Heading: " :sort nil :lookup #'consult--lookup-candidate)) (buffer (marker-buffer marker)) (pos (marker-position marker))) ;; based on org-agenda-switch-to (unless buffer (user-error "Trying to switch to non-existent buffer")) (pop-to-buffer-same-window buffer) (goto-char pos) (when (derived-mode-p 'org-mode) (org-fold-show-context 'agenda) (run-hooks 'org-agenda-after-show-hook)))) (defun my-consult-org-ql-agenda-format (o) (propertize (org-ql-view--format-element o) 'consult--candidate (org-element-property :org-hd-marker o))) (defun my-consult-org-ql-agenda-match (string) "Return candidates that match STRING. Sort heading matches first, followed by other matches. Within those groups, sort by date and priority." (let* ((query (org-ql--query-string-to-sexp string)) (sort '(date reverse priority)) (heading-query (-tree-map (lambda (x) (if (eq x 'rifle) 'heading x)) query)) (matched-heading (mapcar #'my-consult-org-ql-agenda-format (org-ql-select 'org-agenda-files heading-query :action 'element-with-markers :sort sort))) (all-matches (mapcar #'my-consult-org-ql-agenda-format (org-ql-select 'org-agenda-files query :action 'element-with-markers :sort sort)))) (append matched-heading (seq-difference all-matches matched-heading)))) (use-package org-ql :bind ("M-s a" . my-consult-org-ql-agenda-jump))
Along the way, I learned how to use consult to complete using
consult--dynamic-collection
and add consult--candidate
so that I
can reuse consult--lookup-candidate
and consult--jump-state
. Neat!
Someday I'd like to figure out how to add a sorting function and sort by headers without having to reimplement the other sorts. In the meantime, this might be enough to help me get started.