fix: positioned properties rendering

This commit is contained in:
Tienson Qin
2025-12-29 12:51:31 +08:00
parent a04050d988
commit 5ed49d7377
5 changed files with 393 additions and 55 deletions

View File

@@ -1,4 +1,5 @@
{:deps
{:paths ["src" "../../resources"]
:deps
{org.clojure/clojure {:mvn/version "1.11.1"}
rum/rum {:git/url "https://github.com/logseq/rum" ;; fork
:sha "5d672bf84ed944414b9f61eeb83808ead7be9127"}

View File

@@ -215,10 +215,72 @@ a:hover {
.block-content {
white-space: pre-wrap;
display: flex;
gap: 10px;
gap: 4px;
align-items: flex-start;
}
.positioned-properties {
display: inline-flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
}
.positioned-properties.block-left, .positioned-properties.block-right {
display: flex;
align-items: center;
margin-top: 2px;
}
.positioned-properties.block-right {
margin-left: auto;
}
.positioned-properties.block-below {
margin: 6px 0 0 22px;
gap: 8px 12px;
}
.positioned-property {
display: inline-flex;
align-items: center;
gap: 4px;
}
.positioned-property .property-name {
color: var(--muted);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.positioned-property .property-value {
color: var(--ink);
}
.property-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
line-height: 1;
font-size: 1rem;
color: currentColor;
}
.property-icon svg {
width: 1rem;
height: 1rem;
}
.property-value-with-icon {
display: inline-flex;
align-items: center;
gap: 4px;
}
.block-text {
flex: 1;
font-size: 16px;

View File

@@ -22,6 +22,7 @@ import { markdown } from "https://esm.sh/@codemirror/lang-markdown@6";
import { sql } from "https://esm.sh/@codemirror/lang-sql@6";
import { css } from "https://esm.sh/@codemirror/lang-css@6";
import { clojure } from "https://esm.sh/@nextjournal/lang-clojure";
import emojiData from "https://esm.sh/@emoji-mart/data@1?bundle";
const katex = katexPkg.default || katexPkg;
const THEME_KEY = "publish-theme";
@@ -35,6 +36,178 @@ document.addEventListener("click", (event) => {
btn.setAttribute("aria-expanded", String(!collapsed));
});
const getEmojiNative = (id) => {
const emoji = emojiData?.emojis?.[id];
if (!emoji) return null;
return emoji?.skins?.[0]?.native || null;
};
const toKebabCase = (value) =>
(value || "")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([a-zA-Z])([0-9])/g, "$1-$2")
.replace(/([0-9])([a-zA-Z])/g, "$1-$2")
.replace(/[_\s]+/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
const toPascalCase = (value) =>
(value || "")
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
const toTablerIconName = (id) => {
if (!id) return null;
return id.startsWith("Icon") ? id : `Icon${toPascalCase(id)}`;
};
const svgNamespace = "http://www.w3.org/2000/svg";
const isReactElement = (node) =>
node &&
typeof node === "object" &&
node.$$typeof &&
node.type &&
node.props;
const setDomAttribute = (el, key, val, isSvg) => {
if (key === "className") {
el.setAttribute("class", val);
return;
}
if (key === "style" && val && typeof val === "object") {
Object.entries(val).forEach(([styleKey, styleVal]) => {
el.style[styleKey] = styleVal;
});
return;
}
if (key === "ref" || key === "key" || key === "children") return;
if (val === true) {
el.setAttribute(key, "");
return;
}
if (val === false || val == null) return;
let attr = key;
if (isSvg) {
if (key === "strokeWidth") attr = "stroke-width";
else if (key === "strokeLinecap") attr = "stroke-linecap";
else if (key === "strokeLinejoin") attr = "stroke-linejoin";
else if (key !== "viewBox" && /[A-Z]/.test(key)) {
attr = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
}
}
el.setAttribute(attr, val);
};
const reactNodeToDom = (node, parentIsSvg = false) => {
if (node == null || node === false) return null;
if (Array.isArray(node)) {
const frag = document.createDocumentFragment();
node.forEach((child) => {
const childNode = reactNodeToDom(child, parentIsSvg);
if (childNode) frag.appendChild(childNode);
});
return frag;
}
if (typeof node === "string" || typeof node === "number") {
return document.createTextNode(String(node));
}
if (node.nodeType) return node;
if (isReactElement(node)) {
if (node.type === Symbol.for("react.fragment")) {
return reactNodeToDom(node.props?.children, parentIsSvg);
}
if (typeof node.type === "function") {
return reactNodeToDom(node.type(node.props), parentIsSvg);
}
const tag = node.type;
const isSvg = parentIsSvg || tag === "svg";
const el = isSvg
? document.createElementNS(svgNamespace, tag)
: document.createElement(tag);
const props = node.props || {};
Object.entries(props).forEach(([key, val]) => {
setDomAttribute(el, key, val, isSvg);
});
const children = props.children;
if (children != null) {
const childNode = reactNodeToDom(children, isSvg);
if (childNode) el.appendChild(childNode);
}
return el;
}
return null;
};
const getTablerExtIcon = (id) => {
const name = toTablerIconName(id);
if (!name) return null;
return window.tablerIcons?.[name] || null;
};
const renderTablerExtIcon = (el, id) => {
const iconFn = getTablerExtIcon(id);
if (!iconFn) return false;
const node = iconFn({ size: 14, stroke: 2 });
if (!node) return false;
el.textContent = "";
const domNode = reactNodeToDom(node);
if (!domNode) return false;
if (domNode.nodeType === 11) {
el.appendChild(domNode);
return true;
}
if (domNode.nodeType) {
if (domNode.tagName === "svg") {
domNode.setAttribute("aria-hidden", "true");
}
el.appendChild(domNode);
return true;
}
return false;
};
const renderPropertyIcons = () => {
const icons = Array.from(
document.querySelectorAll(".property-icon[data-icon-type][data-icon-id]")
);
if (!icons.length) return;
icons.forEach((el) => {
const id = el.dataset.iconId;
const type = el.dataset.iconType;
if (!id) return;
if (type === "emoji") {
const native = getEmojiNative(id);
el.textContent = native || id;
return;
}
el.textContent = "";
el.setAttribute("aria-hidden", "true");
if (type === "tabler-ext-icon") {
if (renderTablerExtIcon(el, id)) return;
const slug = toKebabCase(id);
el.classList.add("tie", `tie-${slug}`);
return;
}
if (type === "tabler-icon") {
if (renderTablerExtIcon(el, id)) return;
const slug = toKebabCase(id);
el.classList.add("ti", `ti-${slug}`);
return;
}
el.textContent = id;
});
};
let sequenceKey = null;
let sequenceTimer = null;
const SEQUENCE_TIMEOUT_MS = 900;
@@ -179,6 +352,10 @@ const initTwitterEmbeds = () => {
const initPublish = () => {
applyTheme(preferredTheme());
renderPropertyIcons();
if (!window.tablerIcons) {
window.addEventListener("load", renderPropertyIcons, { once: true });
}
initTwitterEmbeds();

View File

@@ -80,6 +80,31 @@
[]
nodes-list))
(defn- normalize-nodes
[nodes]
(cond
(nil? nodes) []
(and (vector? nodes) (keyword? (first nodes))) [nodes]
:else nodes))
(defn- icon-span
[icon]
(when (and (map? icon) (string? (:id icon)) (not (string/blank? (:id icon))))
[:span
(cond->
{:class "property-icon"
:data-icon-id (:id icon)
:data-icon-type (name (:type icon))}
(:color icon)
(assoc :style (str "color: " (:color icon) ";")))]))
(defn- with-icon
[icon nodes]
(let [icon-node (icon-span icon)]
(if icon-node
(into [:span {:class "property-value-with-icon"} icon-node] nodes)
nodes)))
(defn- theme-toggle-node
[]
[:button.theme-toggle
@@ -103,6 +128,25 @@
[]
[:script {:type "module" :src "/static/publish.js"}])
(defn- icon-runtime-script
[]
[:script
"(function(){if(window.React&&window.React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED){return;}var s='http://www.w3.org/2000/svg';var k=function(n){return n.replace(/[A-Z]/g,function(m){return'-'+m.toLowerCase();});};var a=function(el,key,val){if(key==='className'){el.setAttribute('class',val);return;}if(key==='style'&&val&&typeof val==='object'){for(var sk in val){el.style[sk]=val[sk];}return;}if(key==='ref'||key==='key'||key==='children'){return;}if(val===true){el.setAttribute(key,'');return;}if(val===false||val==null){return;}var attr=key;if(key==='strokeWidth'){attr='stroke-width';}else if(key==='strokeLinecap'){attr='stroke-linecap';}else if(key==='strokeLinejoin'){attr='stroke-linejoin';}else if(key!=='viewBox'&&/[A-Z]/.test(key)){attr=k(key);}el.setAttribute(attr,val);};var c=function(el,child){if(child==null||child===false){return;}if(Array.isArray(child)){child.forEach(function(n){c(el,n);});return;}if(typeof child==='string'||typeof child==='number'){el.appendChild(document.createTextNode(child));return;}if(child.nodeType){el.appendChild(child);} };var e=function(type,props){var children=Array.prototype.slice.call(arguments,2);if(type===Symbol.for('react.fragment')){var frag=document.createDocumentFragment();children.forEach(function(n){c(frag,n);});return frag;}if(typeof type==='function'){return type(Object.assign({},props,{children:children}));}var isSvg=type==='svg'||(props&&props.xmlns===s);var el=isSvg?document.createElementNS(s,type):document.createElement(type);if(props){for(var p in props){a(el,p,props[p]);}}children.forEach(function(n){c(el,n);});return el;};window.React={createElement:e,forwardRef:function(fn){return fn;},Fragment:Symbol.for('react.fragment'),__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:{ReactCurrentOwner:{current:null}}};window.PropTypes=new Proxy({}, {get:function(){return function(){return null;};}});})();"])
(defn- head-node
[title]
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title title]
(theme-init-script)
(icon-runtime-script)
[:script {:defer true :src "/static/tabler.ext.js"}]
[:link {:rel "stylesheet"
:href "https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.0/dist/tabler-icons.min.css"}]
[:link {:rel "stylesheet" :href "/static/tabler-extension.css"}]
[:link {:rel "stylesheet" :href "/static/publish.css"}]])
(defn property-type
[prop-key property-type-by-ident]
(or (get property-type-by-ident prop-key)
@@ -157,7 +201,9 @@
[(str value)])
(and ref-type? (get entities value))
(entity->link-node (get entities value) ctx)
(let [entity (get entities value)]
(with-icon (:logseq.property/icon entity)
(entity->link-node entity ctx)))
:else
[(str value)])
@@ -216,7 +262,75 @@
[:div.property
[:dt.property-name (property-title k (:property-title-by-ident ctx))]
[:dd.property-value
(into [:span] (property-value->nodes v k ctx entities))]])]))
(into [:span] (normalize-nodes (property-value->nodes v k ctx entities)))]])]))
(defn- property-ui-position
[prop-key ctx]
(when-let [property (get (:property-entity-by-ident ctx) prop-key)]
(:logseq.property/ui-position property)))
(defn- split-properties-by-position
[props ctx]
(reduce (fn [acc [k v]]
(let [position (property-ui-position k ctx)
bucket (case position
(:block-left :block-right :block-below) position
:properties)]
(update acc bucket assoc k v)))
{:properties {}
:block-left {}
:block-right {}
:block-below {}}
props))
(defn- sorted-properties
[props ctx]
(sort-by (fn [[prop-key _]]
(get-in ctx [:property-entity-by-ident prop-key :block/order]))
props))
(defn- class-has?
[class-name target]
(some #{target} (string/split (or class-name "") #"\s+")))
(defn- node-has-class?
[node target]
(when (and (vector? node) (keyword? (first node)))
(let [attrs (second node)]
(and (map? attrs) (class-has? (:class attrs) target)))))
(defn- strip-positioned-value
[node]
(if (node-has-class? node "property-value-with-icon")
(let [[tag attrs & children] node
icon-children (filter #(node-has-class? % "property-icon") children)]
(if (seq icon-children)
(into [tag attrs] icon-children)
node))
node))
(defn- positioned-value-nodes
[value prop-key ctx entities]
(->> (property-value->nodes value prop-key ctx entities)
normalize-nodes
(map strip-positioned-value)))
(defn- render-positioned-properties
[props ctx entities position]
(when (seq props)
(case position
:block-below
[:div.positioned-properties.block-below
(for [[k v] (sorted-properties props ctx)]
[:div.positioned-property
[:span.property-name (property-title k (:property-title-by-ident ctx))]
[:span.property-value
(into [:span] (positioned-value-nodes v k ctx entities))]])]
[:div {:class (str "positioned-properties " (name position))}
(for [[k v] (sorted-properties props ctx)]
[:span.positioned-property
(into [:span] (positioned-value-nodes v k ctx entities))])])))
(def ^:private youtube-regex #"^((?:https?:)?//)?((?:www|m).)?((?:youtube.com|youtu.be|y2u.be|youtube-nocookie.com))(/(?:[\w-]+\?v=|embed/|v/)?)([\w-]+)([\S^\?]+)?$")
(def ^:private vimeo-regex #"^((?:https?:)?//)?((?:www).)?((?:player.vimeo.com|vimeo.com))(/(?:video/)?)([\w-]+)(\S+)?$")
@@ -580,16 +694,23 @@
(let [child-id (:db/id block)
nested (render-block-tree children-by-parent child-id ctx)
has-children? (boolean nested)
properties (render-properties (entity-properties block ctx (:entities ctx))
ctx
(:entities ctx))]
raw-props (entity-properties block ctx (:entities ctx))
{:keys [properties block-left block-right block-below]}
(split-properties-by-position raw-props ctx)
positioned-left (render-positioned-properties block-left ctx (:entities ctx) :block-left)
positioned-right (render-positioned-properties block-right ctx (:entities ctx) :block-right)
positioned-below (render-positioned-properties block-below ctx (:entities ctx) :block-below)
properties (render-properties properties ctx (:entities ctx))]
[:li.block
[:div.block-content
(when positioned-left positioned-left)
(block-display-node block ctx)
(when positioned-right positioned-right)
(when has-children?
[:button.block-toggle
{:type "button" :aria-expanded "true"}
"▾"])]
(when positioned-below positioned-below)
(when properties
[:div.block-properties properties])
(when nested
@@ -705,6 +826,12 @@
acc))
{}
entities)
property-entity-by-ident (reduce (fn [acc [_e entity]]
(if-let [ident (:db/ident entity)]
(assoc acc ident entity)
acc))
{}
entities)
children-by-parent (->> entities
(reduce (fn [acc [e entity]]
(if (and (= (:block/page entity) page-eid)
@@ -723,6 +850,7 @@
:property-title-by-ident property-title-by-ident
:property-type-by-ident property-type-by-ident
:property-hidden-by-ident property-hidden-by-ident
:property-entity-by-ident property-entity-by-ident
:entities entities}
page-properties (render-properties (entity-properties page-entity ctx entities)
ctx
@@ -746,12 +874,7 @@
(for [item tagged-nodes]
(render-tagged-item graph-uuid item))]])
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title page-title]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node page-title)
[:body
[:main.wrap
(toolbar-node
@@ -788,12 +911,7 @@
(or (:updated-at row) 0)))
reverse)
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title (str "Published pages - " graph-uuid)]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node (str "Published pages - " graph-uuid))
[:body
[:main.wrap
(toolbar-node
@@ -818,12 +936,7 @@
(let [rows tag-items
title (or tag-title tag-uuid)
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title (str "Tag - " title)]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node (str "Tag - " title))
[:body
[:main.wrap
(toolbar-node
@@ -846,12 +959,7 @@
(let [rows tag-items
title (or tag-title tag-name)
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title (str "Tag - " title)]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node (str "Tag - " title))
[:body
[:main.wrap
(toolbar-node
@@ -885,12 +993,7 @@
(let [rows ref-items
title (or ref-title ref-name)
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title (str "Ref - " title)]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node (str "Ref - " title))
[:body
[:main.wrap
(toolbar-node
@@ -925,12 +1028,7 @@
[graph-uuid]
(let [title "Page not published"
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title title]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node title)
[:body
[:main.wrap
(toolbar-node
@@ -946,12 +1044,7 @@
[graph-uuid page-uuid wrong?]
(let [title "Protected page"
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title title]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node title)
[:body
[:main.wrap
(toolbar-node
@@ -980,12 +1073,7 @@
[]
(let [title "Page not found"
doc [:html
[:head
[:meta {:charset "utf-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:title title]
(theme-init-script)
[:link {:rel "stylesheet" :href "/static/publish.css"}]]
(head-node title)
[:body
[:main.wrap
(toolbar-node

View File

@@ -10,6 +10,8 @@
(def publish-css (resource/inline "logseq/publish/publish.css"))
(def publish-js (resource/inline "logseq/publish/publish.js"))
(def tabler-ext-js (resource/inline "js/tabler.ext.js"))
(def tabler-extension-css (resource/inline "css/tabler-extension.css"))
(defn- request-password
[request]
@@ -540,6 +542,14 @@
"cache-control" "public, max-age=31536000, immutable"}
(publish-common/cors-headers))})
(and (= path "/static/tabler.ext.js") (= method "GET"))
(js/Response.
tabler-ext-js
#js {:headers (publish-common/merge-headers
#js {"content-type" "text/javascript; charset=utf-8"
"cache-control" "public, max-age=31536000, immutable"}
(publish-common/cors-headers))})
(and (string/starts-with? path "/page/") (= method "GET"))
(handle-page-html request env)