moderate images

This commit is contained in:
Jonathan Gamble
2025-10-12 08:13:44 -05:00
parent 7c20722bfd
commit b1e005e9f5
20 changed files with 260 additions and 92 deletions
+4
View File
@@ -195,6 +195,10 @@ final class LilaComponents(
private val teamRouter: _root_.router.team.Routes = wire[_root_.router.team.Routes]
val router: Router = wire[_root_.router.router.Routes]
// i'll leave this unseemly var injection here (in the most obnoxious place)
// and pay attention to how you fix it. burn after reading
lila.ui.bits.setPicfitOrigin(env.memo.picfitUrl.origin)
lila.common.Uptime.startedAt
UiEnv.setEnv(env)
+6 -4
View File
@@ -36,10 +36,12 @@ final class Dev(env: Env) extends LilaController(env):
env.relay.proxyDomainRegex,
env.relay.proxyHostPort,
env.relay.proxyCredentials,
env.report.api.modelSetting,
env.report.api.promptSetting,
env.ublog.automod.modelSetting,
env.ublog.automod.promptSetting
env.report.automod.imageModelSetting,
env.report.automod.imagePromptSetting,
env.report.api.commsModelSetting,
env.report.api.commsPromptSetting,
env.ublog.ublogAutomod.modelSetting,
env.ublog.ublogAutomod.promptSetting
)
def settings = Secure(_.Settings) { _ ?=> _ ?=>
+3 -1
View File
@@ -147,7 +147,9 @@ final class Main(
lila.memo.PicfitApi.uploadForm.bindFromRequest().value,
lila.ui.bits.imageDesignWidth(rel)
)
.map(url => JsonOk(Json.obj("imageUrl" -> url)))
.map: url =>
// env.report.api.automodImageRequest(url)
JsonOk(Json.obj("imageUrl" -> url))
.recover { case e: Exception => JsonBadRequest(e.getMessage) }
}
+3 -3
View File
@@ -365,7 +365,7 @@ lazy val study = module("study",
).dependsOn(common % "test->test")
lazy val relay = module("relay",
Seq(study, game),
Seq(study, game, report),
Seq(chess.tiebreak) ++ tests.bundle
)
@@ -430,7 +430,7 @@ lazy val msg = module("msg",
)
lazy val forum = module("forum",
Seq(memo, ui),
Seq(memo, ui, report),
Seq()
)
@@ -440,7 +440,7 @@ lazy val forumSearch = module("forumSearch",
)
lazy val team = module("team",
Seq(memo, room, ui),
Seq(memo, room, ui, report),
Seq()
)
+2
View File
@@ -266,6 +266,8 @@ object mon:
object automod:
val request = future("mod.report.automod.request")
def assessment(a: String) = counter("mod.report.automod.assessment").withTag("assessment", a)
val imageRequest = future("mod.report.automod.image.request")
val imageFlagged = counter("mod.report.automod.image.flagged").withoutTags()
object log:
val create = counter("mod.log.create").withoutTags()
object irwin:
+32 -2
View File
@@ -22,7 +22,13 @@ case class PicfitImage(
name: String,
size: Int, // in bytes
createdAt: Instant,
context: Option[String]
context: Option[String],
automod: Option[ImageAutomod] = none
)
case class ImageAutomod(
flagged: Option[String] = none,
processed: Boolean = false
)
case class ImageMetaData(
@@ -94,6 +100,23 @@ final class PicfitApi(coll: Coll, val url: PicfitUrl, ws: StandaloneWSClient, co
.flatMap { _.result[PicfitImage].so(picfitServer.delete) }
.void
def setContext(context: String, ids: ImageId*): Funit =
coll.update.one($inIds(ids), $set("context" -> context), multi = true).void
def setAutomod(id: ImageId, flagged: Option[String]): Funit =
val op = flagged.fold($doc($unset("automod.flagged") ++ $set("automod.processed" -> true))): reason =>
$set("automod.processed" -> true, "automod.flagged" -> reason)
coll.update.one($id(id), op).void
def byIds(ids: ImageId*): Fu[Seq[PicfitImage]] =
coll
.find($inIds(ids))
.cursor[PicfitImage]()
.list(ids.size)
// def flaggedByUser(user: UserId): Fu[Seq[PicfitImage]] =
// coll.find($doc("user" -> user, "automod.flagged" -> $exists(true))).cursor[PicfitImage]().collect()
object bodyImage:
def upload(rel: String, image: FilePart, meta: Option[ImageMetaData], widthCap: Option[Int])(using
me: Me
@@ -147,6 +170,7 @@ object PicfitApi:
private type ByteSource = Source[ByteString, ?]
private type SourcePart = MultipartFormData.FilePart[ByteSource]
private given BSONDocumentHandler[ImageAutomod] = Macros.handler
private given BSONDocumentHandler[PicfitImage] = Macros.handler
// from playframework/transport/client/play-ws/src/main/scala/play/api/libs/ws/WSBodyWritables.scala
@@ -160,7 +184,7 @@ object PicfitApi:
def findInMarkdown(md: Markdown): Set[ImageId] =
// path=ublogBody:mdTLUTfzboGg:wVo9Pqru.jpg
val regex = """(?i)&path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&""".r
val regex = """(?i)[\?&]path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&""".r
regex
.findAllMatchIn(md.value)
.map(_.group(1))
@@ -180,6 +204,10 @@ object PicfitApi:
final class PicfitUrl(config: PicfitConfig)(using Executor) extends lila.core.misc.PicfitUrl:
val origin =
val pathBegin = config.endpointGet.indexOf('/', 8)
if pathBegin == -1 then config.endpointGet else config.endpointGet.slice(0, pathBegin)
// This operation will able you to resize the image to the specified width and height.
// Preserves the aspect ratio
def resize(
@@ -190,6 +218,8 @@ final class PicfitUrl(config: PicfitConfig)(using Executor) extends lila.core.mi
height = ~size.toOption
)
def contain(id: ImageId, size: Int) = display(id, "resize")(width = size, height = size)
// Thumbnail scales the image up or down using the specified resample filter,
// crops it to the specified width and height and returns the transformed image.
// Preserves the aspect ratio
+94 -11
View File
@@ -5,32 +5,60 @@ import play.api.libs.json.*
import play.api.libs.ws.StandaloneWSClient
import play.api.libs.ws.JsonBodyWritables.*
import play.api.libs.ws.JsonBodyReadables.*
import scala.util.matching.Regex.quote
import lila.core.config.Secret
import lila.core.data.Text
import lila.common.autoconfig.AutoConfig
import lila.common.config.given
import lila.memo.SettingStore.Text.given
import lila.core.id.ImageId
private final class Automod(ws: StandaloneWSClient, appConfig: Configuration)(using Executor):
final class Automod(
ws: StandaloneWSClient,
appConfig: Configuration,
settingStore: lila.memo.SettingStore.Builder,
picfitApi: lila.memo.PicfitApi,
picfitUrl: lila.memo.PicfitUrl
)(using Executor):
private val config = appConfig.get[Automod.Config]("automod")
def request(
private val imageIdRe =
raw"""(?i)!\[(?:[^\n]*)\]\(${quote(
picfitUrl.origin
)}[^)\s]+[?&]path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})[^)]*\)""".r
val imagePromptSetting = settingStore[Text](
"imageAutomodPrompt",
text = "Image automod prompt".some,
default = Text("")
)
val imageModelSetting = settingStore[String](
"imageAutomodModel",
text = "Image automod model".some,
default = "Qwen/Qwen2.5-VL-72B-Instruct"
)
def text(
userText: String,
systemPrompt: Text,
model: String,
temperature: Double
temperature: Double = 0,
maxTokens: Int = 4096
): Fu[Option[JsObject]] =
(config.apiKey.value.nonEmpty && systemPrompt.value.nonEmpty && userText.nonEmpty).so:
val body = Json.obj(
"model" -> model,
"temperature" -> temperature,
"max_tokens" -> 4096,
"messages" -> Json.arr(
Json.obj("role" -> "system", "content" -> systemPrompt.value),
Json.obj("role" -> "user", "content" -> userText)
val body = Json
.obj(
"model" -> model,
"temperature" -> temperature,
"max_tokens" -> maxTokens,
"messages" -> Json.arr(
Json.obj("role" -> "system", "content" -> systemPrompt.value),
Json.obj("role" -> "user", "content" -> userText)
)
)
)
ws.url(config.url)
.withHttpHeaders(
"Authorization" -> s"Bearer ${config.apiKey.value}",
@@ -48,6 +76,61 @@ private final class Automod(ws: StandaloneWSClient, appConfig: Configuration)(us
case None => fufail(s"${rsp.status} ${(rsp.body: String).take(500)}")
case Some(res) => fuccess(res)
def markdownImages(markdown: String): Fu[Seq[lila.memo.PicfitImage]] =
val idToUrl = imageIdRe
.findAllMatchIn(markdown)
.map: m =>
val id = lila.core.id.ImageId(m.group(1))
id -> picfitUrl.contain(id, 560)
.toMap
picfitApi
.byIds(idToUrl.keys.toSeq*)
.flatMap:
_.map: pic =>
pic.automod match
case Some(a) if a.processed => fuccess(pic)
case _ =>
image(idToUrl.get(pic.id).get).flatMap: verdict =>
picfitApi
.setAutomod(pic.id, verdict)
.inject:
pic.copy(automod = lila.memo.ImageAutomod(verdict, true).some)
.toSeq.parallel
def image(imageUrl: String): Fu[Option[String]] =
import play.api.libs.json.Json
(config.apiKey.value.nonEmpty && imagePromptSetting.get().value.nonEmpty).so:
val body = Json
.obj(
"model" -> imageModelSetting.get(),
"temperature" -> 0,
"messages" -> Json.arr(
Json.obj(
"role" -> "user",
"content" -> Json.arr(
Json.obj("type" -> "text", "text" -> imagePromptSetting.get().value),
Json.obj("type" -> "image_url", "image_url" -> Json.obj("url" -> imageUrl))
)
)
)
)
ws.url(config.url)
.withHttpHeaders(
"Authorization" -> s"Bearer ${config.apiKey.value}",
"Content-Type" -> "application/json"
)
.post(body)
.map: rsp =>
for
choices <- (rsp.body \ "choices").asOpt[List[JsObject]]
if rsp.status == 200
best <- choices.headOption
msg <- (best \ "message" \ "content").asOpt[String]
trimmed = msg.slice(msg.indexOf('{', msg.indexOf("</think>")), msg.lastIndexOf('}') + 1)
res <- Json.parse(trimmed).asOpt[JsObject]
if ~(res \ "flag").asOpt[Boolean]
yield ~(res \ "reason").asOpt[String]
private object Automod:
case class Config(val url: String, val apiKey: Secret)
given ConfigLoader[Config] = AutoConfig.loader[Config]
+6 -2
View File
@@ -19,12 +19,16 @@ final class Env(
settingStore: lila.memo.SettingStore.Builder,
cacheApi: lila.memo.CacheApi,
appConfig: play.api.Configuration,
ws: play.api.libs.ws.StandaloneWSClient
ws: play.api.libs.ws.StandaloneWSClient,
net: lila.core.config.NetConfig,
picfitUrl: lila.memo.PicfitUrl,
picfitApi: lila.memo.PicfitApi
)(using Executor, NetDomain)(using scheduler: Scheduler):
private def lazyPlaybansOf = () => playbansOf
private lazy val reportColl = db(CollName("report2"))
private val assetDomain = net.assetDomain
lazy val scoreThresholdsSetting = ReportThresholds.makeScoreSetting(settingStore)
@@ -42,7 +46,7 @@ final class Env(
private given UserIdOf[Report.SnoozeKey] = _.snoozerId
private lazy val snoozer = lila.memo.Snoozer[Report.SnoozeKey]("report.snooze", cacheApi)
private val automod = Automod(ws, appConfig)
lazy val automod = wire[Automod]
lazy val api = wire[ReportApi]
lazy val modFilters = new ModReportFilter
+29 -18
View File
@@ -25,7 +25,8 @@ final class ReportApi(
snoozer: lila.memo.Snoozer[Report.SnoozeKey],
thresholds: Thresholds,
automodApi: Automod,
settingStore: lila.memo.SettingStore.Builder
settingStore: lila.memo.SettingStore.Builder,
picfitApi: lila.memo.PicfitApi
)(using Executor, Scheduler, lila.core.config.NetDomain)
extends lila.core.report.ReportApi:
@@ -36,13 +37,13 @@ final class ReportApi(
private lazy val scorer = wire[ReportScore]
val promptSetting = settingStore[Text](
val commsPromptSetting = settingStore[Text](
"commsAutomodPrompt",
text = "Comms automod prompt".some,
default = Text("")
)
val modelSetting = settingStore[String](
val commsModelSetting = settingStore[String](
"commsAutomodModel",
text = "Comms automod model".some,
default = "Qwen/Qwen3-235B-A22B-Instruct-2507-tput"
@@ -313,33 +314,43 @@ final class ReportApi(
for _ <- doProcessReport(selector, unsetInquiry = true)
yield onReportClose()
def automodRequest(
userText: String,
systemPrompt: Text,
model: String,
temperature: Double = 0
): Fu[Option[play.api.libs.json.JsObject]] =
automodApi.request(userText, systemPrompt, model, temperature)
def automodComms(userText: String, url: String)(using me: Me): Funit =
val assessImages =
automodApi
.markdownImages(userText)
.flatMap: images =>
picfitApi.setContext(url, images.map(_.id)*).inject(images)
val assessText = automodApi
.text(
userText,
systemPrompt = commsPromptSetting.get(),
model = commsModelSetting.get()
)
.monSuccess(_.mod.report.automod.request)
for
rsp <- automodRequest(userText, systemPrompt = promptSetting.get(), model = modelSetting.get())
.monSuccess(_.mod.report.automod.request)
(images, textResponse) <- assessImages.zip(assessText)
flaggedImages = images.flatMap(_.automod.flatMap(_.flagged))
suspectOpt <- getSuspect(me)
reporter <- automodReporter
yield for
res <- rsp
res <- textResponse
fromLlm <- (res \ "assessment").asOpt[String]
_ = lila.mon.mod.report.automod.assessment(if fromLlm == "pass" then "ok" else fromLlm).increment()
reason <- Reason(if fromLlm == "other" then "comm" else fromLlm) // to avoid explaining "comm" in prompt
hasFlaggedImages = flaggedImages.size > 0
kamonTag = if hasFlaggedImages then "image" else if fromLlm == "pass" then "ok" else fromLlm
_ = lila.mon.mod.report.automod.assessment(kamonTag).increment()
reason <- fromLlm match
case "pass" if hasFlaggedImages => Reason("comm")
case "other" => Reason("comm") // llm knows "other"
case r => Reason(r)
suspect <- suspectOpt
summary = ~(res \ "reason").asOpt[String]
summary = (flaggedImages ++ (res \ "reason").asOpt[String]).mkString(", ")
yield create(
Candidate(
reporter = reporter,
suspect = suspect,
reason = reason,
text = s"AUTOMOD: $summary $url"
text = (hasFlaggedImages.option("IMAGE") ++ (fromLlm != "pass").option("TEXT")).mkString("/") +
s" AI: $summary $url"
)
).recoverWith: e =>
logger.warn(s"Comms automod failed for ${me.username}: ${e.getMessage}", e)
+3 -2
View File
@@ -31,7 +31,8 @@ final class Env(
settingStore: lila.memo.SettingStore.Builder,
client: lila.search.client.SearchClient,
reportApi: lila.report.ReportApi,
lightUser: lila.core.LightUser.GetterSync
lightUser: lila.core.LightUser.GetterSync,
automod: lila.report.Automod
)(using Executor, Scheduler, play.api.Mode):
export net.{ assetBaseUrl, baseUrl, domain, assetDomain }
@@ -41,7 +42,7 @@ final class Env(
val topic = wire[UblogTopicApi]
val automod = wire[UblogAutomod]
val ublogAutomod = wire[UblogAutomod]
val api: UblogApi = wire[UblogApi]
+2 -2
View File
@@ -22,7 +22,7 @@ final class UblogApi(
picfitApi: PicfitApi,
shutupApi: ShutupApi,
irc: lila.core.irc.IrcApi,
automod: UblogAutomod,
ublogAutomod: UblogAutomod,
config: UblogConfig,
settingStore: lila.memo.SettingStore.Builder,
cacheApi: lila.memo.CacheApi
@@ -194,7 +194,7 @@ final class UblogApi(
def triggerAutomod(post: UblogPost): Fu[Option[UblogAutomod.Assessment]] =
val retries = 5 // 30s, 1m, 2m, 4m, 8m
def attempt(n: Int): Fu[Option[UblogAutomod.Assessment]] =
automod(post, n * 0.1)
ublogAutomod(post, n * 0.1)
.flatMapz: llm =>
val result = post.automod.foldLeft(llm)(_.updateByLLM(_))
for _ <- colls.post.updateField($id(post.id), "automod", result)
+31 -17
View File
@@ -46,8 +46,10 @@ object UblogAutomod:
private given Reads[FuzzyResult] = Json.reads[FuzzyResult]
private final class UblogAutomod(
reportApi: lila.report.ReportApi,
settingStore: lila.memo.SettingStore.Builder
automod: lila.report.Automod,
settingStore: lila.memo.SettingStore.Builder,
picfitApi: lila.memo.PicfitApi,
picfitUrl: lila.memo.PicfitUrl
)(using Executor):
import UblogAutomod.*
@@ -65,22 +67,34 @@ private final class UblogAutomod(
)
private[ublog] def apply(post: UblogPost, temperature: Double = 0): Fu[Option[Assessment]] = post.live.so:
val userText = post.allText.take(40_000) // bin/ublog-automod.mjs, important for hash
reportApi
.automodRequest(
userText = userText,
systemPrompt = promptSetting.get(),
model = modelSetting.get(),
temperature = temperature
)
.map:
_.flatMap(normalize).so: res =>
lila.mon.ublog.automod.quality(res.quality.toString).increment()
lila.mon.ublog.automod.flagged(res.flagged.isDefined).increment()
res.copy(hash = Algo.sha256(userText).hex.take(12).some).some // matches ublog-automod.mjs hash
.monSuccess(_.ublog.automod.request)
val assessImages =
automod
.markdownImages(~post.image.map(i => s"![](${picfitUrl.contain(i.id, 560)})\n") + post.allText)
.flatMap: images =>
picfitApi.setContext(s"/ublog/${post.id}/redirect", images.map(_.id)*).inject(images)
val assessText =
val userText = post.allText.take(40_000) // match bin/ublog-automod.mjs hash
automod
.text(
userText = userText,
systemPrompt = promptSetting.get(),
model = modelSetting.get(),
temperature = temperature
)
.map:
_.flatMap(normalize).so: res =>
lila.mon.ublog.automod.quality(res.quality.toString).increment()
lila.mon.ublog.automod.flagged(res.flagged.isDefined).increment()
res.copy(hash = Algo.sha256(userText).hex.take(12).some).some // match bin/ublog-automod.mjs hash
.monSuccess(_.ublog.automod.request)
assessImages
.zip(assessText)
.map: (imgs, text) =>
val flags = imgs.flatMap(_.automod.flatMap(_.flagged))
if flags.nonEmpty then text.map(t => t.copy(flagged = (t.flagged ++ flags).mkString(", ").some))
else text
private def normalize(rsp: JsObject): Option[Assessment] = // keep in sync with bin/ublog-automod.mjs
private def normalize(rsp: JsObject): Option[Assessment] =
rsp
.asOpt[FuzzyResult]
.flatMap: res =>
+6 -2
View File
@@ -13,6 +13,9 @@ import ScalatagsTemplate.{ *, given }
object bits:
private var picfitOrigin: Option[String] = none // nothing says i love you like a fresh var
def setPicfitOrigin(origin: String) = picfitOrigin = origin.some // injected from the app
val engineFullName = "Stockfish 17.1"
def subnav(mods: Modifier*) = st.aside(cls := "subnav"):
@@ -163,8 +166,9 @@ object bits:
def markdownTextarea(picfitIdPrefix: Option[String])(modifiers: Modifier*) =
div(
cls := "markdown-textarea",
picfitIdPrefix.map(id => attr("data-image-upload-url") := routes.Main.uploadImage(id)),
picfitIdPrefix.flatMap(id => imageDesignWidth(id)).map(dw => attr("data-image-design-width") := dw),
picfitIdPrefix.map(pre => attr("data-image-upload-url") := routes.Main.uploadImage(pre)),
picfitOrigin.map(origin => attr("data-image-download-origin") := origin),
picfitIdPrefix.flatMap(pre => imageDesignWidth(pre)).map(w => attr("data-image-design-width") := w),
attr("data-image-resize-url") := "/image-url"
)(
div(cls := "comment-header")(
+1 -3
View File
@@ -108,9 +108,7 @@ export const env = new (class {
if (this.manifestOk() && taskOk()) {
if (this.startTime) {
const doneMsg = `Done in ${c.green((Date.now() - this.startTime) / 1000 + '')}s`;
this.log(
doneMsg + (this.stdin ? `. Press ${c.grey('<space>')} to clean, ${c.grey('<esc>')} to exit` : ''),
);
this.log(doneMsg + (this.stdin ? `. Press ${c.grey('<space>')} to clean and rebuild` : ''));
}
updateManifest();
this.startTime = undefined;
+1 -1
View File
@@ -20,7 +20,7 @@ type MenuItem = {
site.load.then(() => {
const containers = Array.from(document.querySelectorAll<HTMLElement>('.dropdown-overflow'));
if (containers.length === 0) {
if (site.blindMode || containers.length === 0) {
return;
}
+1
View File
@@ -35,6 +35,7 @@ function wireMarkdownTextarea(markdown: HTMLElement) {
markdown: (text?: string) => (text !== undefined ? (textarea.value = text) : textarea.value),
},
designWidth: Number(markdown.dataset.imageDesignWidth),
origin: markdown.dataset.imageDownloadOrigin,
});
});
+2 -2
View File
@@ -7,12 +7,12 @@ import { currentTheme } from 'lib/device';
import { wireMarkdownImgResizers, wrapImg, naturalSize } from 'lib/view/markdownImgResizer';
export function makeToastEditor(el: HTMLTextAreaElement, text: string = '', height: string = '60vh'): Editor {
const designWidth = Number(el.dataset.imageDesignWidth);
const rewire = () =>
wireMarkdownImgResizers({
root: document.querySelector<HTMLElement>('.toastui-editor-ww-container .ProseMirror')!,
update: { url: updateImage },
designWidth,
designWidth: Number(el.dataset.imageDesignWidth),
origin: el.dataset.imageDownloadOrigin,
});
const editor = newToast(el, text, rewire, height);
rewire();
-1
View File
@@ -294,7 +294,6 @@ export class EditDialog {
],
});
if (dlg.returnValue !== 'save') return;
this.editing().vision = view.querySelector<HTMLTextAreaElement>('.vision')!.value;
this.makeEditView();
this.update();
+2 -2
View File
@@ -241,7 +241,7 @@ export const schema: Schema = deepFreeze<Schema>({
value: { range: { min: 0, max: 100 }, by: 'max' },
requires: 'bot_filters_cplTarget',
title: $trim`
cpl stdev, if given, describes the standard deviation of the folder normal
cpl stdev, if given, describes the standard deviation of the folded normal
distribution from which each move's random cpl target is chosen. if not given, it defaults
to 50. cpl stdev participates in the cpl target
weight calculation. it does not assign its own weight.`,
@@ -313,7 +313,7 @@ export const schema: Schema = deepFreeze<Schema>({
move quality decay is engine independent and can be used to resolve between
scored stockfish and unscored lc0 moves.
it operates on the full list provided by engine behavior and pairs well
it operates on the full list provided by both engines and pairs well
with the think time facet and a crisp chardonnay.`,
},
},
+32 -19
View File
@@ -10,18 +10,29 @@ export type UpdateImageHook =
| { markdown: Prop<string> }
| { url: (img: HTMLElement, newUrl: string, width: number) => void };
type ResizerOptions = {
export type ResizeArgs = {
root: HTMLElement;
update: UpdateImageHook;
designWidth?: number;
origin?: string;
};
export async function wireMarkdownImgResizers({ root, update, designWidth }: ResizerOptions): Promise<void> {
export async function wireMarkdownImgResizers({
root,
update,
designWidth,
origin,
}: ResizeArgs): Promise<void> {
let rootStyle: CSSStyleDeclaration;
let rootPadding: number;
globalImageLinkRe ??= new RegExp(
String.raw`!\[([^\n]*)\]\((${regexQuote(origin ?? 'http')}[^)\s]+[?&]path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})[^)]*)\)`,
'gi',
);
for (const [index, img] of root.querySelectorAll<HTMLImageElement>('img').entries()) {
for (const img of root.querySelectorAll<HTMLImageElement>('img')) {
if (img.closest('.markdown-img-resizer')) continue; // already wrapped
if (origin && !img.src.startsWith(origin)) continue;
await img.decode().catch(() => {});
rootStyle ??= window.getComputedStyle(root);
rootPadding ??= parseInt(rootStyle.paddingLeft) + parseInt(rootStyle.paddingRight);
@@ -32,8 +43,6 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
const rootWidth = root.clientWidth - rootPadding;
const imgClientRect = img.getBoundingClientRect();
const aspectRatio = img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1;
// here, extrinsic is pixels in the current viewport (whereas intrinsic is natural/picfit size)
const oldExtrinsicImgWidth = imgClientRect.width;
const isBottomDrag = handle.className.includes('bottom');
const isCornerDrag = !isBottomDrag && imgClientRect.bottom - down.clientY < 18;
const dir = handle.className.includes('left') ? -1 : 1;
@@ -41,7 +50,7 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
handle.setPointerCapture?.(down.pointerId);
img.style.willChange = 'width,height';
img.style.width = `${oldExtrinsicImgWidth}px`;
img.style.width = `${imgClientRect.width}px`;
img.closest<HTMLElement>('.markdown-img-resizer')!.style.width = '';
const pointermove = (move: PointerEvent) => {
@@ -50,14 +59,14 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
: isBottomDrag
? (move.clientY - down.clientY) * aspectRatio
: dir * 2 * (move.clientX - down.clientX);
const newExtrinsicImgWidth = Math.round(
clamp(oldExtrinsicImgWidth + deltaX, { min: 128, max: rootWidth }),
const viewportImgWidth = Math.round(
clamp(imgClientRect.width + deltaX, { min: 128, max: rootWidth }),
);
img.style.width = `${newExtrinsicImgWidth}px`;
img.style.width = `${viewportImgWidth}px`;
img.dataset.resizeWidth = String(
designWidth ? Math.round((newExtrinsicImgWidth * designWidth) / rootWidth) : newExtrinsicImgWidth,
designWidth ? Math.round((viewportImgWidth * designWidth) / rootWidth) : viewportImgWidth,
);
img.dataset.widthRatio = String(newExtrinsicImgWidth / rootWidth);
img.dataset.widthRatio = String(viewportImgWidth / rootWidth);
};
const pointerup = async () => {
handle.removeEventListener('pointermove', pointermove);
@@ -76,12 +85,13 @@ export async function wireMarkdownImgResizers({ root, update, designWidth }: Res
return;
}
const text = update.markdown();
const path = [...text.matchAll(globalImageLinkRe)][index];
if (!img.dataset.widthRatio || !path[1]) return;
const { imageUrl } = await xhrJson(`/image-url/${path[2]}?width=${img.dataset.resizeWidth}`);
const before = text.slice(0, path.index);
const after = text.slice(path.index! + path[0].length);
update.markdown(before + `![${path[1]}](${imageUrl})` + after);
console.log(img.src, [...text.matchAll(globalImageLinkRe)]);
const link = [...text.matchAll(globalImageLinkRe)].find(l => l[2] === img.src);
if (!link?.[1] || !img.dataset.widthRatio) return;
const { imageUrl } = await xhrJson(`/image-url/${link[3]}?width=${img.dataset.resizeWidth}`);
const before = text.slice(0, link.index);
const after = text.slice(link.index! + link[0].length);
update.markdown(before + `![${link[1]}](${imageUrl})` + after);
};
handle.addEventListener('pointermove', pointermove, { passive: true });
handle.addEventListener('pointerup', pointerup, { passive: true });
@@ -122,12 +132,15 @@ export async function naturalSize(image: Blob): Promise<{ width: number; height:
}
}
let globalImageLinkRe: RegExp;
const imageIdRe = /&path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&/i;
const globalImageLinkRe =
/!\[([^\n]*)\]\(https:[^)\s]+&path=([a-z]\w+:[a-z0-9]{12}:[a-z0-9]{8}\.\w{3,4})&[^)]+\)/gi;
function dragHandles(img: HTMLImageElement): HTMLElement[] {
const span = img.closest('.markdown-img-container') ?? wrapImg({ img });
span.firstElementChild!.classList.add('markdown-img-resizer');
return [...span.querySelectorAll<HTMLElement>('.resize-handle')];
}
function regexQuote(origin: string) {
return origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}