Merge remote-tracking branch 'upstream/v3.0.x-release' into cleanup-base-component
This commit is contained in:
commit
c1955f17cb
@ -5,7 +5,7 @@ import org.slf4j.LoggerFactory
|
||||
|
||||
import java.io.{ ByteArrayInputStream, File }
|
||||
import scala.io.BufferedSource
|
||||
import scala.util.{ Failure, Success, Try }
|
||||
import scala.util.{ Failure, Success }
|
||||
|
||||
object ClientSettings extends SystemConfiguration {
|
||||
var clientSettingsFromFile: Map[String, Object] = Map("" -> "")
|
||||
@ -82,6 +82,24 @@ object ClientSettings extends SystemConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
def getConfigPropertyValueByPathAsListOfStringOrElse(map: Map[String, Any], path: String, alternativeValue: List[String]): List[String] = {
|
||||
getConfigPropertyValueByPath(map, path) match {
|
||||
case Some(configValue: List[String]) => configValue
|
||||
case _ =>
|
||||
logger.debug(s"Config `$path` with type List[String] not found in clientSettings.")
|
||||
alternativeValue
|
||||
}
|
||||
}
|
||||
|
||||
def getConfigPropertyValueByPathAsListOfIntOrElse(map: Map[String, Any], path: String, alternativeValue: List[Int]): List[Int] = {
|
||||
getConfigPropertyValueByPath(map, path) match {
|
||||
case Some(configValue: List[Int]) => configValue
|
||||
case _ =>
|
||||
logger.debug(s"Config `$path` with type List[Int] not found in clientSettings.")
|
||||
alternativeValue
|
||||
}
|
||||
}
|
||||
|
||||
def getConfigPropertyValueByPath(map: Map[String, Any], path: String): Option[Any] = {
|
||||
val keys = path.split("\\.")
|
||||
|
||||
|
@ -4,6 +4,7 @@ import org.apache.pekko.actor.ActorContext
|
||||
|
||||
class AudioCaptionsApp2x(implicit val context: ActorContext)
|
||||
extends UpdateTranscriptPubMsgHdlr
|
||||
with TranscriptionProviderErrorMsgHdlr
|
||||
with AudioFloorChangedVoiceConfEvtMsgHdlr {
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,31 @@
|
||||
package org.bigbluebutton.core.apps.audiocaptions
|
||||
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.bus.MessageBus
|
||||
import org.bigbluebutton.core.db.UserTranscriptionErrorDAO
|
||||
import org.bigbluebutton.core.models.AudioCaptions
|
||||
import org.bigbluebutton.core.running.LiveMeeting
|
||||
|
||||
trait TranscriptionProviderErrorMsgHdlr {
|
||||
this: AudioCaptionsApp2x =>
|
||||
|
||||
def handleTranscriptionProviderErrorMsg(msg: TranscriptionProviderErrorMsg, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
|
||||
val meetingId = liveMeeting.props.meetingProp.intId
|
||||
|
||||
def broadcastEvent(userId: String, errorCode: String, errorMessage: String): Unit = {
|
||||
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, "nodeJSapp")
|
||||
val envelope = BbbCoreEnvelope(TranscriptionProviderErrorEvtMsg.NAME, routing)
|
||||
val header = BbbClientMsgHeader(TranscriptionProviderErrorEvtMsg.NAME, meetingId, userId)
|
||||
val body = TranscriptionProviderErrorEvtMsgBody(errorCode, errorMessage)
|
||||
val event = TranscriptionProviderErrorEvtMsg(header, body)
|
||||
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
|
||||
|
||||
bus.outGW.send(msgEvent)
|
||||
}
|
||||
|
||||
broadcastEvent(msg.header.userId, msg.body.errorCode, msg.body.errorMessage)
|
||||
|
||||
UserTranscriptionErrorDAO.insert(msg.header.userId, msg.header.meetingId, msg.body.errorCode, msg.body.errorMessage)
|
||||
|
||||
}
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
package org.bigbluebutton.core.apps.audiocaptions
|
||||
|
||||
import org.bigbluebutton.ClientSettings.getConfigPropertyValueByPathAsStringOrElse
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.bus.MessageBus
|
||||
import org.bigbluebutton.core.db.CaptionDAO
|
||||
import org.bigbluebutton.core.models.{AudioCaptions, Users2x}
|
||||
import org.bigbluebutton.core.models.{AudioCaptions, UserState, Users2x}
|
||||
import org.bigbluebutton.core.running.LiveMeeting
|
||||
|
||||
import java.sql.Timestamp
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
trait UpdateTranscriptPubMsgHdlr {
|
||||
this: AudioCaptionsApp2x =>
|
||||
@ -25,6 +26,17 @@ trait UpdateTranscriptPubMsgHdlr {
|
||||
bus.outGW.send(msgEvent)
|
||||
}
|
||||
|
||||
def sendPadUpdatePubMsg(userId: String, defaultPad: String, text: String, transcript: Boolean): Unit = {
|
||||
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, "nodeJSapp")
|
||||
val envelope = BbbCoreEnvelope(PadUpdatePubMsg.NAME, routing)
|
||||
val header = BbbClientMsgHeader(PadUpdatePubMsg.NAME, meetingId, userId)
|
||||
val body = PadUpdatePubMsgBody(defaultPad, text, transcript)
|
||||
val event = PadUpdatePubMsg(header, body)
|
||||
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
|
||||
|
||||
bus.outGW.send(msgEvent)
|
||||
}
|
||||
|
||||
// Adapt to the current captions' recording process
|
||||
def editTranscript(
|
||||
userId: String,
|
||||
@ -80,6 +92,28 @@ trait UpdateTranscriptPubMsgHdlr {
|
||||
msg.body.locale,
|
||||
msg.body.result,
|
||||
)
|
||||
|
||||
if(msg.body.result) {
|
||||
val userName = Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId).get match {
|
||||
case u: UserState => u.name
|
||||
case _ => "???"
|
||||
}
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
val formattedTime = now.format(formatter)
|
||||
|
||||
val userSpoke = s"\n $userName ($formattedTime): $transcript"
|
||||
|
||||
val defaultPad = getConfigPropertyValueByPathAsStringOrElse(
|
||||
liveMeeting.clientSettings,
|
||||
"public.captions.defaultPad",
|
||||
alternativeValue = ""
|
||||
)
|
||||
|
||||
sendPadUpdatePubMsg(msg.header.userId, defaultPad, userSpoke, transcript = true)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ trait PadUpdatePubMsgHdlr {
|
||||
bus.outGW.send(msgEvent)
|
||||
}
|
||||
|
||||
if (Pads.hasAccess(liveMeeting, msg.body.externalId, msg.header.userId)) {
|
||||
if (Pads.hasAccess(liveMeeting, msg.body.externalId, msg.header.userId) || msg.body.transcript == true) {
|
||||
Pads.getGroup(liveMeeting.pads, msg.body.externalId) match {
|
||||
case Some(group) => broadcastEvent(group.groupId, msg.body.externalId, msg.body.text)
|
||||
case _ =>
|
||||
|
@ -0,0 +1,41 @@
|
||||
package org.bigbluebutton.core.apps.users
|
||||
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.models.{ UserState, Users2x }
|
||||
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
|
||||
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
|
||||
import org.bigbluebutton.core.domain.MeetingState2x
|
||||
|
||||
trait SetUserSpeechOptionsMsgHdlr extends RightsManagementTrait {
|
||||
this: UsersApp =>
|
||||
|
||||
val liveMeeting: LiveMeeting
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleSetUserSpeechOptionsReqMsg(msg: SetUserSpeechOptionsReqMsg): Unit = {
|
||||
log.info("handleSetUserSpeechOptionsReqMsg: partialUtterances={} minUtteranceLength={} userId={}", msg.body.partialUtterances, msg.body.minUtteranceLength, msg.header.userId)
|
||||
|
||||
def broadcastUserSpeechOptionsChanged(user: UserState, partialUtterances: Boolean, minUtteranceLength: Int): Unit = {
|
||||
val routingChange = Routing.addMsgToClientRouting(
|
||||
MessageTypes.BROADCAST_TO_MEETING,
|
||||
liveMeeting.props.meetingProp.intId, user.intId
|
||||
)
|
||||
val envelopeChange = BbbCoreEnvelope(UserSpeechOptionsChangedEvtMsg.NAME, routingChange)
|
||||
val headerChange = BbbClientMsgHeader(UserSpeechOptionsChangedEvtMsg.NAME, liveMeeting.props.meetingProp.intId, user.intId)
|
||||
|
||||
val bodyChange = UserSpeechOptionsChangedEvtMsgBody(partialUtterances, minUtteranceLength)
|
||||
val eventChange = UserSpeechOptionsChangedEvtMsg(headerChange, bodyChange)
|
||||
val msgEventChange = BbbCommonEnvCoreMsg(envelopeChange, eventChange)
|
||||
outGW.send(msgEventChange)
|
||||
}
|
||||
|
||||
for {
|
||||
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId)
|
||||
} yield {
|
||||
var changeLocale: Option[UserState] = None;
|
||||
//changeLocale = Users2x.setUserSpeechLocale(liveMeeting.users2x, msg.header.userId, msg.body.locale)
|
||||
broadcastUserSpeechOptionsChanged(user, msg.body.partialUtterances, msg.body.minUtteranceLength)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
package org.bigbluebutton.core.apps.users
|
||||
|
||||
import org.bigbluebutton.ClientSettings.{ getConfigPropertyValueByPathAsListOfIntOrElse, getConfigPropertyValueByPathAsListOfStringOrElse }
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.apps.RightsManagementTrait
|
||||
import org.bigbluebutton.core.db.UserConnectionStatusDAO
|
||||
import org.bigbluebutton.core.models.{ UserState, Users2x }
|
||||
import org.bigbluebutton.core.models.Users2x
|
||||
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
|
||||
|
||||
trait UserConnectionAliveReqMsgHdlr extends RightsManagementTrait {
|
||||
@ -13,13 +14,42 @@ trait UserConnectionAliveReqMsgHdlr extends RightsManagementTrait {
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleUserConnectionAliveReqMsg(msg: UserConnectionAliveReqMsg): Unit = {
|
||||
log.info("handleUserConnectionAliveReqMsg: userId={}", msg.body.userId)
|
||||
log.info("handleUserConnectionAliveReqMsg: networkRttInMs={} userId={}", msg.body.networkRttInMs, msg.body.userId)
|
||||
|
||||
for {
|
||||
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
|
||||
} yield {
|
||||
UserConnectionStatusDAO.updateUserAlive(user.intId)
|
||||
val rtt: Option[Double] = msg.body.networkRttInMs match {
|
||||
case 0 => None
|
||||
case rtt: Double => Some(rtt)
|
||||
}
|
||||
|
||||
val status = getLevelFromRtt(msg.body.networkRttInMs)
|
||||
|
||||
UserConnectionStatusDAO.updateUserAlive(user.intId, rtt, status)
|
||||
}
|
||||
}
|
||||
|
||||
def getLevelFromRtt(networkRttInMs: Double): String = {
|
||||
val levelOptions = getConfigPropertyValueByPathAsListOfStringOrElse(
|
||||
liveMeeting.clientSettings,
|
||||
"public.stats.level",
|
||||
List("warning", "danger", "critical")
|
||||
)
|
||||
|
||||
val rttOptions = getConfigPropertyValueByPathAsListOfIntOrElse(
|
||||
liveMeeting.clientSettings,
|
||||
"public.stats.rtt",
|
||||
List(500, 1000, 2000)
|
||||
)
|
||||
|
||||
val statusRttXLevel = levelOptions.zip(rttOptions).reverse
|
||||
|
||||
val statusFound = statusRttXLevel.collectFirst {
|
||||
case (level, rtt) if networkRttInMs > rtt => level
|
||||
}
|
||||
|
||||
statusFound.getOrElse("normal")
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,25 +0,0 @@
|
||||
package org.bigbluebutton.core.apps.users
|
||||
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.apps.RightsManagementTrait
|
||||
import org.bigbluebutton.core.db.UserConnectionStatusDAO
|
||||
import org.bigbluebutton.core.models.{ UserState, Users2x }
|
||||
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
|
||||
|
||||
trait UserConnectionUpdateRttReqMsgHdlr extends RightsManagementTrait {
|
||||
this: UsersApp =>
|
||||
|
||||
val liveMeeting: LiveMeeting
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleUserConnectionUpdateRttReqMsg(msg: UserConnectionUpdateRttReqMsg): Unit = {
|
||||
log.info("handleUserConnectionUpdateRttReqMsg: networkRttInMs={} userId={}", msg.body.networkRttInMs, msg.body.userId)
|
||||
|
||||
for {
|
||||
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
|
||||
} yield {
|
||||
UserConnectionStatusDAO.updateUserRtt(user.intId, msg.body.networkRttInMs)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -158,6 +158,7 @@ class UsersApp(
|
||||
with RegisterUserReqMsgHdlr
|
||||
with ChangeUserRoleCmdMsgHdlr
|
||||
with SetUserSpeechLocaleMsgHdlr
|
||||
with SetUserSpeechOptionsMsgHdlr
|
||||
with SyncGetUsersMeetingRespMsgHdlr
|
||||
with LogoutAndEndMeetingCmdMsgHdlr
|
||||
with SetRecordingStatusCmdMsgHdlr
|
||||
@ -168,7 +169,6 @@ class UsersApp(
|
||||
with ChangeUserPinStateReqMsgHdlr
|
||||
with ChangeUserMobileFlagReqMsgHdlr
|
||||
with UserConnectionAliveReqMsgHdlr
|
||||
with UserConnectionUpdateRttReqMsgHdlr
|
||||
with ChangeUserReactionEmojiReqMsgHdlr
|
||||
with ChangeUserRaiseHandReqMsgHdlr
|
||||
with ChangeUserAwayReqMsgHdlr
|
||||
|
@ -101,7 +101,8 @@ object PluginDataChannelMessageDAO {
|
||||
DatabaseConnection.db.run(
|
||||
sqlu"""UPDATE "pluginDataChannelMessage" SET
|
||||
"deletedAt" = current_timestamp
|
||||
WHERE "meetingId" = ${meetingId}
|
||||
WHERE "deletedAt" is null
|
||||
AND "meetingId" = ${meetingId}
|
||||
AND "pluginName" = ${pluginName}
|
||||
AND "dataChannel" = ${dataChannel}
|
||||
AND "messageId" = ${messageId}"""
|
||||
|
@ -5,22 +5,24 @@ import scala.concurrent.ExecutionContext.Implicits.global
|
||||
import scala.util.{ Failure, Success }
|
||||
|
||||
case class UserConnectionStatusDbModel(
|
||||
userId: String,
|
||||
meetingId: String,
|
||||
connectionAliveAt: Option[java.sql.Timestamp],
|
||||
userClientResponseAt: Option[java.sql.Timestamp],
|
||||
networkRttInMs: Option[Double]
|
||||
userId: String,
|
||||
meetingId: String,
|
||||
connectionAliveAt: Option[java.sql.Timestamp],
|
||||
networkRttInMs: Option[Double],
|
||||
status: String,
|
||||
statusUpdatedAt: Option[java.sql.Timestamp]
|
||||
)
|
||||
|
||||
class UserConnectionStatusDbTableDef(tag: Tag) extends Table[UserConnectionStatusDbModel](tag, None, "user_connectionStatus") {
|
||||
override def * = (
|
||||
userId, meetingId, connectionAliveAt, userClientResponseAt, networkRttInMs
|
||||
userId, meetingId, connectionAliveAt, networkRttInMs, status, statusUpdatedAt
|
||||
) <> (UserConnectionStatusDbModel.tupled, UserConnectionStatusDbModel.unapply)
|
||||
val userId = column[String]("userId", O.PrimaryKey)
|
||||
val meetingId = column[String]("meetingId")
|
||||
val connectionAliveAt = column[Option[java.sql.Timestamp]]("connectionAliveAt")
|
||||
val userClientResponseAt = column[Option[java.sql.Timestamp]]("userClientResponseAt")
|
||||
val networkRttInMs = column[Option[Double]]("networkRttInMs")
|
||||
val status = column[String]("status")
|
||||
val statusUpdatedAt = column[Option[java.sql.Timestamp]]("statusUpdatedAt")
|
||||
}
|
||||
|
||||
object UserConnectionStatusDAO {
|
||||
@ -32,8 +34,9 @@ object UserConnectionStatusDAO {
|
||||
userId = userId,
|
||||
meetingId = meetingId,
|
||||
connectionAliveAt = None,
|
||||
userClientResponseAt = None,
|
||||
networkRttInMs = None
|
||||
networkRttInMs = None,
|
||||
status = "normal",
|
||||
statusUpdatedAt = None
|
||||
)
|
||||
)
|
||||
).onComplete {
|
||||
@ -42,28 +45,23 @@ object UserConnectionStatusDAO {
|
||||
}
|
||||
}
|
||||
|
||||
def updateUserAlive(userId: String) = {
|
||||
def updateUserAlive(userId: String, rtt: Option[Double], status: String) = {
|
||||
DatabaseConnection.db.run(
|
||||
TableQuery[UserConnectionStatusDbTableDef]
|
||||
.filter(_.userId === userId)
|
||||
.map(t => (t.connectionAliveAt))
|
||||
.update(Some(new java.sql.Timestamp(System.currentTimeMillis())))
|
||||
.map(t => (t.connectionAliveAt, t.networkRttInMs, t.status, t.statusUpdatedAt))
|
||||
.update(
|
||||
(
|
||||
Some(new java.sql.Timestamp(System.currentTimeMillis())),
|
||||
rtt,
|
||||
status,
|
||||
Some(new java.sql.Timestamp(System.currentTimeMillis())),
|
||||
)
|
||||
)
|
||||
).onComplete {
|
||||
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated connectionAliveAt on UserConnectionStatus table!")
|
||||
case Failure(e) => DatabaseConnection.logger.debug(s"Error updating connectionAliveAt on UserConnectionStatus: $e")
|
||||
}
|
||||
}
|
||||
|
||||
def updateUserRtt(userId: String, networkRttInMs: Double) = {
|
||||
DatabaseConnection.db.run(
|
||||
TableQuery[UserConnectionStatusDbTableDef]
|
||||
.filter(_.userId === userId)
|
||||
.map(t => (t.networkRttInMs, t.userClientResponseAt))
|
||||
.update((Some(networkRttInMs), Some(new java.sql.Timestamp(System.currentTimeMillis()))))
|
||||
).onComplete {
|
||||
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated networkRttInMs on UserConnectionStatus table!")
|
||||
case Failure(e) => DatabaseConnection.logger.debug(s"Error updating networkRttInMs on UserConnectionStatus: $e")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,48 @@
|
||||
package org.bigbluebutton.core.db
|
||||
|
||||
import org.bigbluebutton.core.models.{ VoiceUserState }
|
||||
import slick.jdbc.PostgresProfile.api._
|
||||
|
||||
import scala.concurrent.ExecutionContext.Implicits.global
|
||||
import scala.util.{ Failure, Success }
|
||||
|
||||
case class UserTranscriptionErrorDbModel(
|
||||
userId: String,
|
||||
meetingId: String,
|
||||
errorCode: String,
|
||||
errorMessage: String,
|
||||
lastUpdatedAt: java.sql.Timestamp = new java.sql.Timestamp(System.currentTimeMillis())
|
||||
)
|
||||
|
||||
class UserTranscriptionErrorDbTableDef(tag: Tag) extends Table[UserTranscriptionErrorDbModel](tag, None, "user_transcriptionError") {
|
||||
override def * = (
|
||||
userId, meetingId, errorCode, errorMessage, lastUpdatedAt
|
||||
) <> (UserTranscriptionErrorDbModel.tupled, UserTranscriptionErrorDbModel.unapply)
|
||||
val userId = column[String]("userId", O.PrimaryKey)
|
||||
val meetingId = column[String]("meetingId")
|
||||
val errorCode = column[String]("errorCode")
|
||||
val errorMessage = column[String]("errorMessage")
|
||||
val lastUpdatedAt = column[java.sql.Timestamp]("lastUpdatedAt")
|
||||
}
|
||||
|
||||
object UserTranscriptionErrorDAO {
|
||||
def insert(userId: String, meetingId: String, errorCode: String, errorMessage: String) = {
|
||||
DatabaseConnection.db.run(
|
||||
TableQuery[UserTranscriptionErrorDbTableDef].insertOrUpdate(
|
||||
UserTranscriptionErrorDbModel(
|
||||
userId = userId,
|
||||
meetingId = meetingId,
|
||||
errorCode = errorCode,
|
||||
errorMessage = errorMessage,
|
||||
lastUpdatedAt = new java.sql.Timestamp(System.currentTimeMillis()),
|
||||
)
|
||||
)
|
||||
).onComplete {
|
||||
case Success(rowsAffected) => {
|
||||
DatabaseConnection.logger.debug(s"$rowsAffected row(s) inserted on user_transcriptionError table!")
|
||||
}
|
||||
case Failure(e) => DatabaseConnection.logger.debug(s"Error inserting user_transcriptionError: $e")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -7,12 +7,10 @@ import org.bigbluebutton.SystemConfiguration
|
||||
object AudioCaptions extends SystemConfiguration {
|
||||
def setFloor(audioCaptions: AudioCaptions, userId: String) = audioCaptions.floor = userId
|
||||
|
||||
def isFloor(audioCaptions: AudioCaptions, userId: String) = audioCaptions.floor == userId
|
||||
def isFloor(audioCaptions: AudioCaptions, userId: String) = true
|
||||
|
||||
def parseTranscript(transcript: String): String = {
|
||||
val words = transcript.split("\\s+") // Split on whitespaces
|
||||
val lines = words.grouped(transcriptWords).toArray // Group each X words into lines
|
||||
lines.takeRight(transcriptLines).map(l => l.mkString(" ")).mkString("\n") // Join the last X lines
|
||||
transcript
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -113,10 +113,10 @@ class ReceivedJsonMsgHandlerActor(
|
||||
routeGenericMsg[ChangeUserMobileFlagReqMsg](envelope, jsonNode)
|
||||
case UserConnectionAliveReqMsg.NAME =>
|
||||
routeGenericMsg[UserConnectionAliveReqMsg](envelope, jsonNode)
|
||||
case UserConnectionUpdateRttReqMsg.NAME =>
|
||||
routeGenericMsg[UserConnectionUpdateRttReqMsg](envelope, jsonNode)
|
||||
case SetUserSpeechLocaleReqMsg.NAME =>
|
||||
routeGenericMsg[SetUserSpeechLocaleReqMsg](envelope, jsonNode)
|
||||
case SetUserSpeechOptionsReqMsg.NAME =>
|
||||
routeGenericMsg[SetUserSpeechOptionsReqMsg](envelope, jsonNode)
|
||||
|
||||
// Poll
|
||||
case StartCustomPollReqMsg.NAME =>
|
||||
@ -406,6 +406,8 @@ class ReceivedJsonMsgHandlerActor(
|
||||
// AudioCaptions
|
||||
case UpdateTranscriptPubMsg.NAME =>
|
||||
routeGenericMsg[UpdateTranscriptPubMsg](envelope, jsonNode)
|
||||
case TranscriptionProviderErrorMsg.NAME =>
|
||||
routeGenericMsg[TranscriptionProviderErrorMsg](envelope, jsonNode)
|
||||
|
||||
// GroupChats
|
||||
case GetGroupChatsReqMsg.NAME =>
|
||||
|
@ -405,8 +405,8 @@ class MeetingActor(
|
||||
case m: ChangeUserPinStateReqMsg => usersApp.handleChangeUserPinStateReqMsg(m)
|
||||
case m: ChangeUserMobileFlagReqMsg => usersApp.handleChangeUserMobileFlagReqMsg(m)
|
||||
case m: UserConnectionAliveReqMsg => usersApp.handleUserConnectionAliveReqMsg(m)
|
||||
case m: UserConnectionUpdateRttReqMsg => usersApp.handleUserConnectionUpdateRttReqMsg(m)
|
||||
case m: SetUserSpeechLocaleReqMsg => usersApp.handleSetUserSpeechLocaleReqMsg(m)
|
||||
case m: SetUserSpeechOptionsReqMsg => usersApp.handleSetUserSpeechOptionsReqMsg(m)
|
||||
|
||||
// Client requested to eject user
|
||||
case m: EjectUserFromMeetingCmdMsg =>
|
||||
@ -590,6 +590,7 @@ class MeetingActor(
|
||||
|
||||
// AudioCaptions
|
||||
case m: UpdateTranscriptPubMsg => audioCaptionsApp2x.handle(m, liveMeeting, msgBus)
|
||||
case m: TranscriptionProviderErrorMsg => audioCaptionsApp2x.handleTranscriptionProviderErrorMsg(m, liveMeeting, msgBus)
|
||||
|
||||
// GroupChat
|
||||
case m: CreateGroupChatReqMsg =>
|
||||
|
@ -79,7 +79,6 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging {
|
||||
case m: ChangeUserPinStateReqMsg => logMessage(msg)
|
||||
case m: ChangeUserMobileFlagReqMsg => logMessage(msg)
|
||||
case m: UserConnectionAliveReqMsg => logMessage(msg)
|
||||
case m: UserConnectionUpdateRttReqMsg => logMessage(msg)
|
||||
case m: ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg => logMessage(msg)
|
||||
case m: ScreenshareRtmpBroadcastStoppedVoiceConfEvtMsg => logMessage(msg)
|
||||
case m: ScreenshareRtmpBroadcastStartedEvtMsg => logMessage(msg)
|
||||
|
@ -1,5 +1,12 @@
|
||||
package org.bigbluebutton.common2.msgs
|
||||
|
||||
object TranscriptionProviderErrorMsg { val NAME = "TranscriptionProviderErrorMsg" }
|
||||
case class TranscriptionProviderErrorMsg(header: BbbClientMsgHeader, body: TranscriptionProviderErrorMsgBody) extends StandardMsg
|
||||
case class TranscriptionProviderErrorMsgBody(
|
||||
errorCode: String,
|
||||
errorMessage: String,
|
||||
)
|
||||
|
||||
// In messages
|
||||
object UpdateTranscriptPubMsg { val NAME = "UpdateTranscriptPubMsg" }
|
||||
case class UpdateTranscriptPubMsg(header: BbbClientMsgHeader, body: UpdateTranscriptPubMsgBody) extends StandardMsg
|
||||
@ -14,6 +21,10 @@ case class UpdateTranscriptPubMsgBody(
|
||||
)
|
||||
|
||||
// Out messages
|
||||
object TranscriptionProviderErrorEvtMsg { val NAME = "TranscriptionProviderErrorEvtMsg" }
|
||||
case class TranscriptionProviderErrorEvtMsg(header: BbbClientMsgHeader, body: TranscriptionProviderErrorEvtMsgBody) extends BbbCoreMsg
|
||||
case class TranscriptionProviderErrorEvtMsgBody(errorCode: String, errorMessage: String)
|
||||
|
||||
object TranscriptUpdatedEvtMsg { val NAME = "TranscriptUpdatedEvtMsg" }
|
||||
case class TranscriptUpdatedEvtMsg(header: BbbClientMsgHeader, body: TranscriptUpdatedEvtMsgBody) extends BbbCoreMsg
|
||||
case class TranscriptUpdatedEvtMsgBody(transcriptId: String, transcript: String, locale: String, result: Boolean)
|
||||
|
@ -107,7 +107,7 @@ case class PadTailEvtMsgBody(externalId: String, tail: String)
|
||||
// client -> apps
|
||||
object PadUpdatePubMsg { val NAME = "PadUpdatePubMsg" }
|
||||
case class PadUpdatePubMsg(header: BbbClientMsgHeader, body: PadUpdatePubMsgBody) extends StandardMsg
|
||||
case class PadUpdatePubMsgBody(externalId: String, text: String)
|
||||
case class PadUpdatePubMsgBody(externalId: String, text: String, transcript: Boolean)
|
||||
|
||||
// apps -> pads
|
||||
object PadUpdateCmdMsg { val NAME = "PadUpdateCmdMsg" }
|
||||
|
@ -298,14 +298,7 @@ case class ChangeUserMobileFlagReqMsgBody(userId: String, mobile: Boolean)
|
||||
*/
|
||||
object UserConnectionAliveReqMsg { val NAME = "UserConnectionAliveReqMsg" }
|
||||
case class UserConnectionAliveReqMsg(header: BbbClientMsgHeader, body: UserConnectionAliveReqMsgBody) extends StandardMsg
|
||||
case class UserConnectionAliveReqMsgBody(userId: String)
|
||||
|
||||
/**
|
||||
* Sent from client to inform the RTT (time it took to send the Alive and receive confirmation).
|
||||
*/
|
||||
object UserConnectionUpdateRttReqMsg { val NAME = "UserConnectionUpdateRttReqMsg" }
|
||||
case class UserConnectionUpdateRttReqMsg(header: BbbClientMsgHeader, body: UserConnectionUpdateRttReqMsgBody) extends StandardMsg
|
||||
case class UserConnectionUpdateRttReqMsgBody(userId: String, networkRttInMs: Double)
|
||||
case class UserConnectionAliveReqMsgBody(userId: String, networkRttInMs: Double)
|
||||
|
||||
/**
|
||||
* Sent to all clients about a user mobile flag.
|
||||
@ -532,3 +525,11 @@ case class SetUserSpeechLocaleReqMsgBody(locale: String, provider: String)
|
||||
object UserSpeechLocaleChangedEvtMsg { val NAME = "UserSpeechLocaleChangedEvtMsg" }
|
||||
case class UserSpeechLocaleChangedEvtMsg(header: BbbClientMsgHeader, body: UserSpeechLocaleChangedEvtMsgBody) extends BbbCoreMsg
|
||||
case class UserSpeechLocaleChangedEvtMsgBody(locale: String, provider: String)
|
||||
|
||||
object SetUserSpeechOptionsReqMsg { val NAME = "SetUserSpeechOptionsReqMsg" }
|
||||
case class SetUserSpeechOptionsReqMsg(header: BbbClientMsgHeader, body: SetUserSpeechOptionsReqMsgBody) extends StandardMsg
|
||||
case class SetUserSpeechOptionsReqMsgBody(partialUtterances: Boolean, minUtteranceLength: Int)
|
||||
|
||||
object UserSpeechOptionsChangedEvtMsg { val NAME = "UserSpeechOptionsChangedEvtMsg" }
|
||||
case class UserSpeechOptionsChangedEvtMsg(header: BbbClientMsgHeader, body: UserSpeechOptionsChangedEvtMsgBody) extends BbbCoreMsg
|
||||
case class UserSpeechOptionsChangedEvtMsgBody(partialUtterances: Boolean, minUtteranceLength: Int)
|
||||
|
@ -9,10 +9,8 @@ module.exports = {
|
||||
],
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 'latest',
|
||||
'sourceType': 'module',
|
||||
},
|
||||
'rules': {
|
||||
'require-jsdoc': 0,
|
||||
'camelcase': 0,
|
||||
'max-len': 0,
|
||||
},
|
||||
};
|
@ -1,4 +0,0 @@
|
||||
const settings = require('./settings');
|
||||
const config = settings;
|
||||
|
||||
module.exports = config;
|
@ -1,41 +1,40 @@
|
||||
{
|
||||
"log": {
|
||||
"level": "info",
|
||||
"msgName": "PresAnnStatusMsg"
|
||||
},
|
||||
"shared": {
|
||||
"presDir": "/var/bigbluebutton",
|
||||
"presAnnDropboxDir": "/tmp/pres-ann-dropbox",
|
||||
"cairosvg": "/usr/bin/cairosvg",
|
||||
"ghostscript": "/usr/bin/gs",
|
||||
"imagemagick": "/usr/bin/convert",
|
||||
"pdftocairo": "/usr/bin/pdftocairo"
|
||||
},
|
||||
"collector": {
|
||||
"pngWidthRasterizedSlides": 2560
|
||||
},
|
||||
"process": {
|
||||
"whiteboardTextEncoding": "utf-8",
|
||||
"maxImageWidth": 1440,
|
||||
"maxImageHeight": 1080,
|
||||
"textScaleFactor": 2,
|
||||
"pointsPerInch": 72,
|
||||
"pixelsPerInch": 96
|
||||
},
|
||||
"notifier": {
|
||||
"pod_id": "DEFAULT_PRESENTATION_POD",
|
||||
"is_downloadable": "false",
|
||||
"msgName": "NewPresFileAvailableMsg"
|
||||
},
|
||||
"bbbWebAPI": "http://127.0.0.1:8090",
|
||||
"bbbPadsAPI": "http://127.0.0.1:9002",
|
||||
"redis": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379,
|
||||
"password": null,
|
||||
"channels": {
|
||||
"queue": "exportJobs",
|
||||
"publish": "to-akka-apps-redis-channel"
|
||||
}
|
||||
"log": {
|
||||
"level": "info",
|
||||
"msgName": "PresAnnStatusMsg"
|
||||
},
|
||||
"shared": {
|
||||
"presAnnDropboxDir": "/tmp/pres-ann-dropbox",
|
||||
"cairosvg": "/usr/bin/cairosvg",
|
||||
"ghostscript": "/usr/bin/gs"
|
||||
},
|
||||
"process": {
|
||||
"maxImageWidth": 1440,
|
||||
"maxImageHeight": 1080,
|
||||
"pointsPerInch": 72,
|
||||
"pixelsPerInch": 96,
|
||||
"cairoSVGUnsafeFlag": false
|
||||
},
|
||||
"notifier": {
|
||||
"pod_id": "DEFAULT_PRESENTATION_POD",
|
||||
"is_downloadable": "false",
|
||||
"msgName": "NewPresFileAvailableMsg"
|
||||
},
|
||||
"bbbWebAPI": "http://127.0.0.1:8090",
|
||||
"bbbPadsAPI": "http://127.0.0.1:9002",
|
||||
"redis": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 6379,
|
||||
"password": null,
|
||||
"channels": {
|
||||
"queue": "exportJobs",
|
||||
"publish": "to-akka-apps-redis-channel"
|
||||
}
|
||||
},
|
||||
"fonts": {
|
||||
"draw": "/usr/local/share/fonts/CaveatBrush-Regular-2015-09-23.ttf",
|
||||
"sans": "/usr/local/share/fonts/CrimsonPro[wght]-1.003.ttf",
|
||||
"serif": "/usr/local/share/fonts/SourceSansPro-Regular-2.045.ttf",
|
||||
"mono": "/usr/local/share/fonts/SourceCodePro-Regular-2.038.ttf"
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
const config = require('../../config');
|
||||
import fs from 'fs';
|
||||
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
|
||||
|
||||
const {level} = config.log;
|
||||
const trace = level.toLowerCase() === 'trace';
|
||||
@ -14,7 +15,7 @@ const parse = (messages) => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = class Logger {
|
||||
export default class Logger {
|
||||
constructor(context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
const config = require('../../config');
|
||||
import fs from 'fs';
|
||||
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
|
||||
|
||||
const EXPORT_STATUSES = Object.freeze({
|
||||
COLLECTING: 'COLLECTING',
|
||||
@ -86,7 +87,4 @@ class NewPresFileAvailableMsg {
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
PresAnnStatusMsg,
|
||||
NewPresFileAvailableMsg,
|
||||
};
|
||||
export {PresAnnStatusMsg, NewPresFileAvailableMsg};
|
||||
|
@ -1,5 +1,6 @@
|
||||
const {Worker} = require('worker_threads');
|
||||
const path = require('path');
|
||||
import {Worker} from 'worker_threads';
|
||||
import {fileURLToPath} from 'url';
|
||||
import path from 'path';
|
||||
|
||||
const WorkerTypes = Object.freeze({
|
||||
Collector: 'collector',
|
||||
@ -9,19 +10,27 @@ const WorkerTypes = Object.freeze({
|
||||
|
||||
const kickOffWorker = (workerType, workerData) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerPath = path.join(__dirname, '..', '..', 'workers', `${workerType}.js`);
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const workerPath = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'workers',
|
||||
`${workerType}.js`);
|
||||
const worker = new Worker(workerPath, {workerData});
|
||||
worker.on('message', resolve);
|
||||
worker.on('error', reject);
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Worker '${workerType}' stopped with exit code ${code}`));
|
||||
reject(
|
||||
new Error(`Worker '${workerType}' stopped with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = class WorkerStarter {
|
||||
export default class WorkerStarter {
|
||||
constructor(workerData) {
|
||||
this.workerData = workerData;
|
||||
}
|
||||
|
@ -1,19 +1,21 @@
|
||||
const Logger = require('./lib/utils/logger');
|
||||
const WorkerStarter = require('./lib/utils/worker-starter');
|
||||
const config = require('./config');
|
||||
const fs = require('fs');
|
||||
const redis = require('redis');
|
||||
const {commandOptions} = require('redis');
|
||||
const path = require('path');
|
||||
import Logger from './lib/utils/logger.js';
|
||||
import WorkerStarter from './lib/utils/worker-starter.js';
|
||||
import fs from 'fs';
|
||||
import redis, {commandOptions} from 'redis';
|
||||
import path from 'path';
|
||||
|
||||
const logger = new Logger('presAnn Master');
|
||||
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
|
||||
|
||||
logger.info('Running bbb-export-annotations');
|
||||
|
||||
(async () => {
|
||||
const client = redis.createClient({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
socket: {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port
|
||||
}
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
503
bbb-export-annotations/package-lock.json
generated
503
bbb-export-annotations/package-lock.json
generated
@ -1,20 +1,22 @@
|
||||
{
|
||||
"name": "bbb-export-annotations",
|
||||
"version": "0.0.1",
|
||||
"version": "2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bbb-export-annotations",
|
||||
"version": "0.0.1",
|
||||
"version": "2.0",
|
||||
"dependencies": {
|
||||
"@svgdotjs/svg.js": "^3.2.0",
|
||||
"axios": "^1.6.5",
|
||||
"form-data": "^4.0.0",
|
||||
"opentype.js": "^1.3.4",
|
||||
"perfect-freehand": "^1.0.16",
|
||||
"probe-image-size": "^7.2.3",
|
||||
"redis": "^4.0.3",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"xmlbuilder2": "^3.0.2"
|
||||
"svgdom": "^0.1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.20.0",
|
||||
@ -165,55 +167,24 @@
|
||||
"@node-redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/dom": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
|
||||
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
|
||||
"node_modules/@svgdotjs/svg.js": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz",
|
||||
"integrity": "sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Fuzzyma"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.4.36",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.36.tgz",
|
||||
"integrity": "sha512-5lxnyLEYFskErRPenYItLRSge5DjrJngYKdVjRSrWfza9G6KkgHEXi0vUZiyUeMU5JfXH1YnvXZzSp8ul88o2Q==",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/url": "1.0.4",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
"legacy-swc-helpers": "npm:@swc/helpers@=0.4.14",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/infra": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
|
||||
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
|
||||
"dependencies": {
|
||||
"@oozcitak/util": "8.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/url": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
|
||||
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
|
||||
"dependencies": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oozcitak/util": {
|
||||
"version": "8.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
|
||||
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==",
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.16.tgz",
|
||||
"integrity": "sha512-ydLaGVfQOQ6hI1xK2A5nVh8bl0OGoIfYMxPWHqqYe9bTkWCfqiVvZoh2I/QF2sNSkZzZyROBoTefIEI+PB6iIA=="
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
|
||||
@ -302,6 +273,25 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
@ -312,6 +302,14 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@ -337,6 +335,14 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
|
||||
@ -416,6 +422,11 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
@ -590,18 +601,6 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
|
||||
@ -647,8 +646,7 @@
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
@ -712,6 +710,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.2.tgz",
|
||||
"integrity": "sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.4.2",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
@ -821,6 +835,20 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz",
|
||||
"integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
@ -859,8 +887,7 @@
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
@ -913,6 +940,15 @@
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/legacy-swc-helpers": {
|
||||
"name": "@swc/helpers",
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz",
|
||||
"integrity": "sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@ -998,6 +1034,21 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/opentype.js": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
|
||||
"integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==",
|
||||
"dependencies": {
|
||||
"string.prototype.codepointat": "^0.2.1",
|
||||
"tiny-inflate": "^1.0.3"
|
||||
},
|
||||
"bin": {
|
||||
"ot": "bin/ot"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
|
||||
@ -1015,6 +1066,11 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -1083,6 +1139,14 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.0.3.tgz",
|
||||
@ -1136,6 +1200,11 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.0.tgz",
|
||||
"integrity": "sha512-Xj8/MEIhhfj9X2rmD9iJ4Gga9EFqVlpMj3vfLnV2r/Mh5jRMryNV+6lWh9GdJtDBcBSPIqzRdfBQ3wDtNFv/uw=="
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@ -1190,11 +1259,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
|
||||
},
|
||||
"node_modules/stream-parser": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
|
||||
@ -1216,6 +1280,11 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||
},
|
||||
"node_modules/string.prototype.codepointat": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
|
||||
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
@ -1252,12 +1321,31 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/svgdom": {
|
||||
"version": "0.1.17",
|
||||
"resolved": "https://registry.npmjs.org/svgdom/-/svgdom-0.1.17.tgz",
|
||||
"integrity": "sha512-lhmZmXF0T2mCCrHnuNxgXnS72H3NJzTXz2oNoWxp+XGWpeuwlkJFko44ci9XEcrY1+zIE+8v99I561V010ZJyQ==",
|
||||
"dependencies": {
|
||||
"fontkit": "^2.0.2",
|
||||
"image-size": "^1.0.2",
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Fuzzyma"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
|
||||
},
|
||||
"node_modules/truncate-utf8-bytes": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||
@ -1266,6 +1354,11 @@
|
||||
"utf8-byte-length": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@ -1290,6 +1383,24 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@ -1340,41 +1451,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/xmlbuilder2": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",
|
||||
"integrity": "sha512-h4MUawGY21CTdhV4xm3DG9dgsqyhDkZvVJBx88beqX8wJs3VgyGQgAn5VreHuae6unTQxh115aMK5InCVmOIKw==",
|
||||
"dependencies": {
|
||||
"@oozcitak/dom": "1.15.10",
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8",
|
||||
"@types/node": "*",
|
||||
"js-yaml": "3.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder2/node_modules/js-yaml": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
|
||||
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@ -1491,43 +1567,20 @@
|
||||
"integrity": "sha512-+nTn6EewVj3GlUXPuD3dgheWqo219jTxlo6R+pg24OeVvFHx9aFGGiyOgj3vBPhWUdRZ0xMcujXV5ki4fbLyMw==",
|
||||
"requires": {}
|
||||
},
|
||||
"@oozcitak/dom": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
|
||||
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
|
||||
"@svgdotjs/svg.js": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.0.tgz",
|
||||
"integrity": "sha512-Tr8p+QVP7y+QT1GBlq1Tt57IvedVH8zCPoYxdHLX0Oof3a/PqnC/tXAkVufv1JQJfsDHlH/UrjcDfgxSofqSNA=="
|
||||
},
|
||||
"@swc/helpers": {
|
||||
"version": "0.4.36",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.36.tgz",
|
||||
"integrity": "sha512-5lxnyLEYFskErRPenYItLRSge5DjrJngYKdVjRSrWfza9G6KkgHEXi0vUZiyUeMU5JfXH1YnvXZzSp8ul88o2Q==",
|
||||
"requires": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/url": "1.0.4",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
"legacy-swc-helpers": "npm:@swc/helpers@=0.4.14",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"@oozcitak/infra": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
|
||||
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
|
||||
"requires": {
|
||||
"@oozcitak/util": "8.3.8"
|
||||
}
|
||||
},
|
||||
"@oozcitak/url": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
|
||||
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
|
||||
"requires": {
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8"
|
||||
}
|
||||
},
|
||||
"@oozcitak/util": {
|
||||
"version": "8.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
|
||||
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ=="
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.16.tgz",
|
||||
"integrity": "sha512-ydLaGVfQOQ6hI1xK2A5nVh8bl0OGoIfYMxPWHqqYe9bTkWCfqiVvZoh2I/QF2sNSkZzZyROBoTefIEI+PB6iIA=="
|
||||
},
|
||||
"acorn": {
|
||||
"version": "8.8.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
|
||||
@ -1595,6 +1648,11 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
@ -1605,6 +1663,14 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"requires": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@ -1621,6 +1687,11 @@
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="
|
||||
},
|
||||
"cluster-key-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
|
||||
@ -1685,6 +1756,11 @@
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
|
||||
},
|
||||
"dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
|
||||
},
|
||||
"doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
@ -1811,11 +1887,6 @@
|
||||
"eslint-visitor-keys": "^3.3.0"
|
||||
}
|
||||
},
|
||||
"esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
|
||||
},
|
||||
"esquery": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
|
||||
@ -1849,8 +1920,7 @@
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
@ -1894,6 +1964,22 @@
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw=="
|
||||
},
|
||||
"fontkit": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.2.tgz",
|
||||
"integrity": "sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==",
|
||||
"requires": {
|
||||
"@swc/helpers": "^0.4.2",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
@ -1973,6 +2059,14 @@
|
||||
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
|
||||
"dev": true
|
||||
},
|
||||
"image-size": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz",
|
||||
"integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==",
|
||||
"requires": {
|
||||
"queue": "6.0.2"
|
||||
}
|
||||
},
|
||||
"import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
@ -2002,8 +2096,7 @@
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"is-extglob": {
|
||||
"version": "2.1.1",
|
||||
@ -2047,6 +2140,14 @@
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"legacy-swc-helpers": {
|
||||
"version": "npm:@swc/helpers@0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz",
|
||||
"integrity": "sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==",
|
||||
"requires": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@ -2114,6 +2215,15 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"opentype.js": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
|
||||
"integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==",
|
||||
"requires": {
|
||||
"string.prototype.codepointat": "^0.2.1",
|
||||
"tiny-inflate": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"optionator": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
|
||||
@ -2128,6 +2238,11 @@
|
||||
"word-wrap": "^1.2.3"
|
||||
}
|
||||
},
|
||||
"pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
|
||||
},
|
||||
"parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -2181,6 +2296,14 @@
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true
|
||||
},
|
||||
"queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"requires": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"redis": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.0.3.tgz",
|
||||
@ -2219,6 +2342,11 @@
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true
|
||||
},
|
||||
"restructure": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.0.tgz",
|
||||
"integrity": "sha512-Xj8/MEIhhfj9X2rmD9iJ4Gga9EFqVlpMj3vfLnV2r/Mh5jRMryNV+6lWh9GdJtDBcBSPIqzRdfBQ3wDtNFv/uw=="
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@ -2261,11 +2389,6 @@
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true
|
||||
},
|
||||
"sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
|
||||
},
|
||||
"stream-parser": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
|
||||
@ -2289,6 +2412,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"string.prototype.codepointat": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
|
||||
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
@ -2313,12 +2441,27 @@
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"svgdom": {
|
||||
"version": "0.1.17",
|
||||
"resolved": "https://registry.npmjs.org/svgdom/-/svgdom-0.1.17.tgz",
|
||||
"integrity": "sha512-lhmZmXF0T2mCCrHnuNxgXnS72H3NJzTXz2oNoWxp+XGWpeuwlkJFko44ci9XEcrY1+zIE+8v99I561V010ZJyQ==",
|
||||
"requires": {
|
||||
"fontkit": "^2.0.2",
|
||||
"image-size": "^1.0.2",
|
||||
"sax": "^1.2.4"
|
||||
}
|
||||
},
|
||||
"text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"dev": true
|
||||
},
|
||||
"tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
|
||||
},
|
||||
"truncate-utf8-bytes": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||
@ -2327,6 +2470,11 @@
|
||||
"utf8-byte-length": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
},
|
||||
"type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@ -2342,6 +2490,24 @@
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"dev": true
|
||||
},
|
||||
"unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"requires": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"requires": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@ -2383,37 +2549,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true
|
||||
},
|
||||
"xmlbuilder2": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",
|
||||
"integrity": "sha512-h4MUawGY21CTdhV4xm3DG9dgsqyhDkZvVJBx88beqX8wJs3VgyGQgAn5VreHuae6unTQxh115aMK5InCVmOIKw==",
|
||||
"requires": {
|
||||
"@oozcitak/dom": "1.15.10",
|
||||
"@oozcitak/infra": "1.0.8",
|
||||
"@oozcitak/util": "8.3.8",
|
||||
"@types/node": "*",
|
||||
"js-yaml": "3.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"requires": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
|
||||
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
|
||||
"requires": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
|
@ -1,19 +1,22 @@
|
||||
{
|
||||
"name": "bbb-export-annotations",
|
||||
"version": "0.0.1",
|
||||
"version": "2.0",
|
||||
"description": "BigBlueButton's Presentation Annotation Exporter",
|
||||
"scripts": {
|
||||
"start": "node master.js",
|
||||
"lint:fix": "eslint --fix **/*.js"
|
||||
"lint:fix": "eslint --fix '**/*.js' --ignore-pattern 'node_modules/'",
|
||||
"lint": "eslint '**/*.js' --ignore-pattern 'node_modules/'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@svgdotjs/svg.js": "^3.2.0",
|
||||
"axios": "^1.6.5",
|
||||
"form-data": "^4.0.0",
|
||||
"opentype.js": "^1.3.4",
|
||||
"perfect-freehand": "^1.0.16",
|
||||
"probe-image-size": "^7.2.3",
|
||||
"redis": "^4.0.3",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"xmlbuilder2": "^3.0.2"
|
||||
"svgdom": "^0.1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.20.0",
|
||||
@ -22,5 +25,6 @@
|
||||
"engines": {
|
||||
"node": ">=18.16.0",
|
||||
"npm": ">=9.5.0"
|
||||
}
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
287
bbb-export-annotations/shapes/Arrow.js
Normal file
287
bbb-export-annotations/shapes/Arrow.js
Normal file
@ -0,0 +1,287 @@
|
||||
import {Path, Marker, Defs} from '@svgdotjs/svg.js';
|
||||
import {Shape} from './Shape.js';
|
||||
import {TAU, circleFromThreePoints, normalize, rotate}
|
||||
from '../shapes/helpers.js';
|
||||
import {ColorTypes} from '../shapes/Shape.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG path from Tldraw v2 arrow data.
|
||||
*
|
||||
* @class Arrow
|
||||
* @extends {Shape}
|
||||
*/
|
||||
export class Arrow extends Shape {
|
||||
/**
|
||||
* @param {Object} arrow - The arrow shape JSON.
|
||||
*/
|
||||
constructor(arrow) {
|
||||
super(arrow);
|
||||
this.start = this.props?.start;
|
||||
this.end = this.props?.end;
|
||||
this.arrowheadStart = this.props?.arrowheadStart;
|
||||
this.arrowheadEnd = this.props?.arrowheadEnd;
|
||||
this.bend = this.props?.bend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the midpoint of a curve considering the bend of the line.
|
||||
* The midpoint is adjusted by the bend property to represent the
|
||||
* actual midpoint of a quadratic Bezier curve defined by the start
|
||||
* and end points with the bend as control point offset.
|
||||
*
|
||||
* @return {number[]} An array containing the x and y coordinates.
|
||||
*/
|
||||
getMidpoint() {
|
||||
const mid = [
|
||||
(this.start.x + this.end.x) / 2,
|
||||
(this.start.y + this.end.y) / 2];
|
||||
|
||||
const unitVector = normalize([
|
||||
this.end.x - this.start.x,
|
||||
this.end.y - this.start.y]);
|
||||
|
||||
const unitRotated = rotate(unitVector);
|
||||
const bendOffset = [
|
||||
unitRotated[0] * -this.bend,
|
||||
unitRotated[1] * -this.bend];
|
||||
|
||||
const middle = [
|
||||
mid[0] + bendOffset[0],
|
||||
mid[1] + bendOffset[1]];
|
||||
|
||||
return middle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the angle in radians between the line segments joining the start
|
||||
* point to the midpoint and the endpoint to the midpoint of a given set of
|
||||
* points. Assumes that `this.getMidpoint()` is a method which calculates the
|
||||
* midpoint between the start and end points, `this.start` is the start point,
|
||||
* and `this.end` is the end point of the line segments. The points are
|
||||
* objects with `x` and `y`properties representing their coordinates.
|
||||
* @return {number} Angle between the two line segments at the midpoint.
|
||||
*/
|
||||
getTheta() {
|
||||
const [middleX, middleY] = this.getMidpoint();
|
||||
|
||||
const ab = Math.hypot(this.start.y - middleY, this.start.x - middleX);
|
||||
const bc = Math.hypot(middleY - this.end.y, middleX - this.end.x);
|
||||
const ca = Math.hypot(this.end.y - this.start.y, this.end.x - this.start.x);
|
||||
const theta = Math.acos((bc * bc + ca * ca - ab * ab) / (2 * bc * ca)) * 2;
|
||||
return theta || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the path for the arrow, considering straight and curved lines.
|
||||
*
|
||||
* @return {string} - The SVG path string.
|
||||
*/
|
||||
constructPath() {
|
||||
const [startX, startY] = [this.start.x, this.start.y];
|
||||
const [endX, endY] = [this.end.x, this.end.y];
|
||||
const bend = this.bend;
|
||||
|
||||
const isStraightLine = (bend.toFixed(2) === '0.00');
|
||||
const straightLine = `M ${startX} ${startY} L ${endX} ${endY}`;
|
||||
|
||||
if (isStraightLine) {
|
||||
return straightLine;
|
||||
}
|
||||
|
||||
const [middleX, middleY] = this.getMidpoint();
|
||||
|
||||
const [,, r] = circleFromThreePoints(
|
||||
[startX, startY],
|
||||
[middleX, middleY],
|
||||
[endX, endY]);
|
||||
|
||||
// Could not calculate a circle
|
||||
if (!r) {
|
||||
return straightLine;
|
||||
}
|
||||
|
||||
const radius = r.toFixed(2);
|
||||
|
||||
// Whether to draw the longer arc
|
||||
const theta = this.getTheta();
|
||||
const largeArcFlag = theta > (TAU / 4) ? '1' : '0';
|
||||
|
||||
// Clockwise or counterclockwise
|
||||
const sweepFlag = ((endX - startX) * (middleY - startY) -
|
||||
(middleX - startX) * (endY - startY) > 0 ? '0' : '1');
|
||||
|
||||
const path = `M ${startX} ${startY} ` +
|
||||
`A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ` +
|
||||
`${endX} ${endY}`;
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the tangent angles at the start and end points of a path.
|
||||
* This method assumes that the path is an instance of a class with
|
||||
* a method `pointAt` which returns a point with `x` and `y` properties
|
||||
* given a distance along the path.
|
||||
*
|
||||
* @param {Object} path - SVG.js path with the `pointAt` method.
|
||||
* @return {Object} An object with `startAngleDegrees` and `endAngleDegrees`
|
||||
* properties.
|
||||
*/
|
||||
getTangentAngleAtEnds(path) {
|
||||
const length = path.length();
|
||||
const start = path.pointAt(0);
|
||||
const epsilon = 0.01; // A small value
|
||||
const end = path.pointAt(length);
|
||||
|
||||
// Get points just a little further along the path to calculate the tangent
|
||||
const startTangentPoint = path.pointAt(epsilon);
|
||||
const endTangentPoint = path.pointAt(length - epsilon);
|
||||
|
||||
// Calculate angles using Math.atan2 to find the slope of the tangent
|
||||
const startAngleRadians = Math.atan2(
|
||||
startTangentPoint.y - start.y,
|
||||
startTangentPoint.x - start.x) + + TAU / 2;
|
||||
|
||||
const endAngleRadians = Math.atan2(
|
||||
end.y - endTangentPoint.y,
|
||||
end.x - endTangentPoint.x);
|
||||
|
||||
// Convert to degrees
|
||||
const startAngleDegrees = startAngleRadians * (360 / TAU);
|
||||
const endAngleDegrees = endAngleRadians * (360 / TAU);
|
||||
|
||||
return {startAngleDegrees, endAngleDegrees};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a marker element with specified attributes
|
||||
* and shape based on the type. The marker is configured with default
|
||||
* properties which can be overridden according to the type.
|
||||
* The marker type determines the path and fill of the SVG element.
|
||||
*
|
||||
* @param {string} type - One of 'arrow', 'diamond', 'triangle', 'inverted',
|
||||
* 'square', 'dot', 'bar'.
|
||||
* @param {string} url - URL reference in SVG.
|
||||
* @param {number} [angle=0] - Angle in degrees for the marker.
|
||||
* @return {Marker} A new Marker instance.
|
||||
*/
|
||||
createMarker(type, url, angle = 0) {
|
||||
const arrowMarker = new Marker({
|
||||
id: url,
|
||||
viewBox: '0 0 10 10',
|
||||
refX: '5',
|
||||
refY: '5',
|
||||
markerWidth: '6',
|
||||
markerHeight: '6',
|
||||
orient: angle,
|
||||
});
|
||||
|
||||
const fillColor = Shape.colorToHex(this.color, ColorTypes.FillColor);
|
||||
|
||||
switch (type) {
|
||||
case 'arrow':
|
||||
arrowMarker.path('M 0 0 L 10 5 L 0 10 Z').fill(this.shapeColor);
|
||||
break;
|
||||
case 'diamond':
|
||||
arrowMarker.path('M 5 0 L 10 5 L 5 10 L 0 5 z')
|
||||
.stroke(this.shapeColor)
|
||||
.fill(fillColor);
|
||||
break;
|
||||
case 'triangle':
|
||||
arrowMarker.path('M 0 0 L 10 5 L 0 10 Z')
|
||||
.stroke(this.shapeColor)
|
||||
.fill(fillColor);
|
||||
break;
|
||||
case 'inverted':
|
||||
arrowMarker.attr('orient', angle + 180);
|
||||
arrowMarker.path('M 0 0 L 10 5 L 0 10 Z')
|
||||
.stroke(this.shapeColor)
|
||||
.fill(fillColor);
|
||||
break;
|
||||
case 'square':
|
||||
arrowMarker.path('M 0 0 L 10 0 L 10 10 L 0 10 Z')
|
||||
.stroke(this.shapeColor)
|
||||
.fill(fillColor);
|
||||
break;
|
||||
case 'dot':
|
||||
const circleSize = 5;
|
||||
arrowMarker.attr('refX', '0');
|
||||
arrowMarker.attr('refY', circleSize / 2);
|
||||
arrowMarker.attr('markerUnits', 'strokeWidth');
|
||||
arrowMarker.attr('markerWidth', '6');
|
||||
arrowMarker.attr('markerHeight', '6');
|
||||
arrowMarker.stroke('context-stroke');
|
||||
arrowMarker.fill(fillColor);
|
||||
arrowMarker.circle(circleSize)
|
||||
.stroke(this.shapeColor)
|
||||
.fill(fillColor);
|
||||
break;
|
||||
case 'bar':
|
||||
arrowMarker.attr('refX', '0');
|
||||
arrowMarker.attr('refY', '2.5');
|
||||
arrowMarker.path('M 0 0 L 0 -5 L 2 -5 L 2 5 L 0 5 Z')
|
||||
.stroke(this.shapeColor)
|
||||
.fill(this.shapeColor);
|
||||
break;
|
||||
default:
|
||||
arrowMarker.path('M 0 0 L 10 5 L 0 10 z').fill(this.shapeColor);
|
||||
}
|
||||
return arrowMarker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the arrow object as an SVG group element.
|
||||
*
|
||||
* @return {G} - An SVG group element.
|
||||
*/
|
||||
draw() {
|
||||
const arrowGroup = this.shapeGroup;
|
||||
const arrowPath = new Path();
|
||||
const pathData = this.constructPath();
|
||||
|
||||
arrowPath.attr({
|
||||
'd': pathData,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
'fill': 'none',
|
||||
});
|
||||
|
||||
const angles = this.getTangentAngleAtEnds(arrowPath);
|
||||
|
||||
// If there are arrowheads, create the markers
|
||||
if (this.arrowheadStart !== 'none' || this.arrowheadEnd !== 'none') {
|
||||
const defs = new Defs();
|
||||
|
||||
// There is an arrowhead at the start
|
||||
if (this.arrowheadStart !== 'none') {
|
||||
const url = `${this.arrowheadStart}-${this.id}-start`;
|
||||
const startMarker = this.createMarker(
|
||||
this.arrowheadStart,
|
||||
url,
|
||||
angles.startAngleDegrees);
|
||||
|
||||
defs.add(startMarker);
|
||||
arrowPath.attr('marker-start', `url(#${url})`);
|
||||
}
|
||||
|
||||
// There is an arrowhead at the end
|
||||
if (this.arrowheadEnd !== 'none') {
|
||||
const url = `${this.arrowheadEnd}-${this.id}-end`;
|
||||
const endMarker = this.createMarker(
|
||||
this.arrowheadEnd,
|
||||
url,
|
||||
angles.endAngleDegrees);
|
||||
defs.add(endMarker);
|
||||
arrowPath.attr('marker-end', `url(#${url})`);
|
||||
}
|
||||
|
||||
arrowGroup.add(defs);
|
||||
}
|
||||
|
||||
arrowGroup.add(arrowPath);
|
||||
this.drawLabel(arrowGroup);
|
||||
|
||||
return arrowGroup;
|
||||
}
|
||||
}
|
43
bbb-export-annotations/shapes/ArrowDown.js
Normal file
43
bbb-export-annotations/shapes/ArrowDown.js
Normal file
@ -0,0 +1,43 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG down-arrow shape.
|
||||
*
|
||||
* @class ArrowDown
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class ArrowDown extends Geo {
|
||||
/**
|
||||
* Draws a down arrow shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the down arrow.
|
||||
*/
|
||||
draw() {
|
||||
const w = this.w;
|
||||
const h = this.h + this.growY;
|
||||
const ox = w * 0.16;
|
||||
const oy = Math.min(w, h) * 0.38;
|
||||
|
||||
const points = [
|
||||
[ox, 0],
|
||||
[w - ox, 0],
|
||||
[w - ox, h - oy],
|
||||
[w, h - oy],
|
||||
[w / 2, h],
|
||||
[0, h - oy],
|
||||
[ox, h - oy],
|
||||
].map(([x, y]) => `${x},${y}`).join(' ');
|
||||
|
||||
const arrowGroup = this.shapeGroup;
|
||||
const arrow = new SVGPolygon();
|
||||
arrow.plot(points)
|
||||
.stroke({color: this.shapeColor, width: this.thickness})
|
||||
.style({dasharray: this.dasharray});
|
||||
|
||||
this.setFill(arrow);
|
||||
arrowGroup.add(arrow);
|
||||
this.drawLabel(arrowGroup);
|
||||
|
||||
return arrowGroup;
|
||||
}
|
||||
}
|
43
bbb-export-annotations/shapes/ArrowLeft.js
Normal file
43
bbb-export-annotations/shapes/ArrowLeft.js
Normal file
@ -0,0 +1,43 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG left-arrow shape.
|
||||
*
|
||||
* @class ArrowLeft
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class ArrowLeft extends Geo {
|
||||
/**
|
||||
* Draws a left arrow shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the left arrow.
|
||||
*/
|
||||
draw() {
|
||||
const w = this.w;
|
||||
const h = this.h + this.growY;
|
||||
const ox = Math.min(w, h) * 0.38;
|
||||
const oy = h * 0.16;
|
||||
|
||||
const points = [
|
||||
[ox, 0],
|
||||
[ox, oy],
|
||||
[w, oy],
|
||||
[w, h - oy],
|
||||
[ox, h - oy],
|
||||
[ox, h],
|
||||
[0, h / 2],
|
||||
].map(([x, y]) => `${x},${y}`).join(' ');
|
||||
|
||||
const arrowGroup = this.shapeGroup;
|
||||
const arrow = new SVGPolygon();
|
||||
arrow.plot(points)
|
||||
.stroke({color: this.shapeColor, width: this.thickness})
|
||||
.style({dasharray: this.dasharray});
|
||||
|
||||
this.setFill(arrow);
|
||||
arrowGroup.add(arrow);
|
||||
this.drawLabel(arrowGroup);
|
||||
|
||||
return arrowGroup;
|
||||
}
|
||||
}
|
43
bbb-export-annotations/shapes/ArrowRight.js
Normal file
43
bbb-export-annotations/shapes/ArrowRight.js
Normal file
@ -0,0 +1,43 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG right-arrow shape.
|
||||
*
|
||||
* @class ArrowRight
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class ArrowRight extends Geo {
|
||||
/**
|
||||
* Draws a right arrow shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the right arrow.
|
||||
*/
|
||||
draw() {
|
||||
const w = this.w;
|
||||
const h = this.h + this.growY;
|
||||
const ox = Math.min(w, h) * 0.38;
|
||||
const oy = h * 0.16;
|
||||
|
||||
const points = [
|
||||
[0, oy],
|
||||
[w - ox, oy],
|
||||
[w - ox, 0],
|
||||
[w, h / 2],
|
||||
[w - ox, h],
|
||||
[w - ox, h - oy],
|
||||
[0, h - oy],
|
||||
].map(([x, y]) => `${x},${y}`).join(' ');
|
||||
|
||||
const arrowGroup = this.shapeGroup;
|
||||
const arrow = new SVGPolygon();
|
||||
arrow.plot(points)
|
||||
.stroke({color: this.shapeColor, width: this.thickness})
|
||||
.style({dasharray: this.dasharray});
|
||||
|
||||
this.setFill(arrow);
|
||||
arrowGroup.add(arrow);
|
||||
this.drawLabel(arrowGroup);
|
||||
|
||||
return arrowGroup;
|
||||
}
|
||||
}
|
43
bbb-export-annotations/shapes/ArrowUp.js
Normal file
43
bbb-export-annotations/shapes/ArrowUp.js
Normal file
@ -0,0 +1,43 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG up-arrow shape.
|
||||
*
|
||||
* @class ArrowUp
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class ArrowUp extends Geo {
|
||||
/**
|
||||
* Draws an up arrow shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the up arrow.
|
||||
*/
|
||||
draw() {
|
||||
const w = this.w;
|
||||
const h = this.h + this.growY;
|
||||
const ox = w * 0.16;
|
||||
const oy = Math.min(w, h) * 0.38;
|
||||
|
||||
const points = [
|
||||
[w / 2, 0],
|
||||
[w, oy],
|
||||
[w - ox, oy],
|
||||
[w - ox, h],
|
||||
[ox, h],
|
||||
[ox, oy],
|
||||
[0, oy],
|
||||
].map(([x, y]) => `${x},${y}`).join(' ');
|
||||
|
||||
const arrowGroup = this.shapeGroup;
|
||||
const arrow = new SVGPolygon();
|
||||
arrow.plot(points)
|
||||
.stroke({color: this.shapeColor, width: this.thickness})
|
||||
.style({dasharray: this.dasharray});
|
||||
|
||||
this.setFill(arrow);
|
||||
arrowGroup.add(arrow);
|
||||
this.drawLabel(arrowGroup);
|
||||
|
||||
return arrowGroup;
|
||||
}
|
||||
}
|
67
bbb-export-annotations/shapes/Checkbox.js
Normal file
67
bbb-export-annotations/shapes/Checkbox.js
Normal file
@ -0,0 +1,67 @@
|
||||
import {Line} from '@svgdotjs/svg.js';
|
||||
import {Rectangle} from './Rectangle.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG "Checkbox" shape, which is a rectangle with a checkmark in it.
|
||||
*
|
||||
* @class Checkbox
|
||||
* @extends {Rectangle}
|
||||
*/
|
||||
export class Checkbox extends Rectangle {
|
||||
/**
|
||||
* Gets the lines to draw a checkmark inside a given width and height.
|
||||
* @param {number} w The width of the bounding rectangle.
|
||||
* @param {number} h The height of the bounding rectangle.
|
||||
* @return {Array} An array of lines, each defined by two arrays
|
||||
* representing points.
|
||||
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx} Adapted from Tldraw.
|
||||
*/
|
||||
static getCheckBoxLines(w, h) {
|
||||
const size = Math.min(w, h) * 0.82;
|
||||
const ox = (w - size) / 2;
|
||||
const oy = (h - size) / 2;
|
||||
|
||||
const clampX = (x) => Math.max(0, Math.min(w, x));
|
||||
const clampY = (y) => Math.max(0, Math.min(h, y));
|
||||
|
||||
return [
|
||||
[
|
||||
[clampX(ox + size * 0.25), clampY(oy + size * 0.52)],
|
||||
[clampX(ox + size * 0.45), clampY(oy + size * 0.82)],
|
||||
],
|
||||
[
|
||||
[clampX(ox + size * 0.45), clampY(oy + size * 0.82)],
|
||||
[clampX(ox + size * 0.82), clampY(oy + size * 0.22)],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a "Checkbox" shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing
|
||||
* the rectangle and the checkmark.
|
||||
*/
|
||||
draw() {
|
||||
// Draw the base rectangle
|
||||
const rectGroup = super.draw();
|
||||
|
||||
// Get the lines for the checkmark
|
||||
const lines = Checkbox.getCheckBoxLines(this.w, this.h + this.growY);
|
||||
|
||||
lines.forEach(([start, end]) => {
|
||||
const line = new Line();
|
||||
line.plot(start[0], start[1], end[0], end[1])
|
||||
.stroke({color: this.shapeColor,
|
||||
width: this.thickness,
|
||||
linecap: 'round'})
|
||||
.style({dasharray: this.dasharray});
|
||||
|
||||
// Add the line to the group
|
||||
rectGroup.add(line);
|
||||
});
|
||||
|
||||
this.drawLabel(rectGroup);
|
||||
|
||||
return rectGroup;
|
||||
}
|
||||
}
|
309
bbb-export-annotations/shapes/Cloud.js
Normal file
309
bbb-export-annotations/shapes/Cloud.js
Normal file
@ -0,0 +1,309 @@
|
||||
import {Path} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
import {angle, rng, TAU, getPointOnCircle, calculateDistance,
|
||||
clockwiseAngleDist} from '../shapes/helpers.js';
|
||||
/**
|
||||
* Class representing a Cloud shape.
|
||||
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/tldraw/src/lib/shapes/geo/cloudOutline.ts} Adapted from Tldraw.
|
||||
*/
|
||||
export class Cloud extends Geo {
|
||||
/**
|
||||
* Generate points on an arc between two given points.
|
||||
*
|
||||
* @param {Object} startPoint - Starting point with 'x' and 'y' properties.
|
||||
* @param {Object} endPoint - End point with 'x' and 'y' properties.
|
||||
* @param {Object|null} center - Center point with 'x' and 'y' properties
|
||||
* @param {number} radius - The radius of the circle.
|
||||
* @param {number} numPoints - The number of points to generate along the arc.
|
||||
* @return {Array} Array of point objects representing the points on the arc.
|
||||
*/
|
||||
static pointsOnArc(startPoint, endPoint, center, radius, numPoints) {
|
||||
if (center === null) {
|
||||
return [startPoint, endPoint];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const startAngle = angle(center, startPoint);
|
||||
const endAngle = angle(center, endPoint);
|
||||
const l = clockwiseAngleDist(startAngle, endAngle);
|
||||
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const t = i / (numPoints - 1);
|
||||
const angle = startAngle + l * t;
|
||||
const point = getPointOnCircle(center.x, center.y, radius, angle);
|
||||
results.push(point);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to get points on the "pill" shape.
|
||||
*
|
||||
* @static
|
||||
* @param {number} width - The width of the pill shape.
|
||||
* @param {number} height - The height of the pill shape.
|
||||
* @param {number} numPoints - The number of points to generate.
|
||||
* @return {Array} - Array of points on the pill shape.
|
||||
*/
|
||||
static getPillPoints(width, height, numPoints) {
|
||||
const radius = Math.min(width, height) / 2;
|
||||
const longSide = Math.max(width, height) - radius * 2;
|
||||
const circumference = TAU * radius + 2 * longSide;
|
||||
const spacing = circumference / numPoints;
|
||||
|
||||
const sections = width > height ?
|
||||
[
|
||||
{type: 'straight', start: {x: radius, y: 0}, delta: {x: 1, y: 0}},
|
||||
{type: 'arc', center: {x: width - radius, y: radius},
|
||||
startAngle: -TAU / 4},
|
||||
{type: 'straight', start: {x: width - radius, y: height},
|
||||
delta: {x: -1, y: 0}},
|
||||
{type: 'arc', center: {x: radius, y: radius}, startAngle: TAU / 4},
|
||||
] :
|
||||
[
|
||||
{type: 'straight', start: {x: width, y: radius}, delta: {x: 0, y: 1}},
|
||||
{type: 'arc', center: {x: radius, y: height - radius}, startAngle: 0},
|
||||
{type: 'straight', start: {x: 0, y: height - radius},
|
||||
delta: {x: 0, y: - 1}},
|
||||
{type: 'arc', center: {x: radius, y: radius}, startAngle: TAU / 2},
|
||||
];
|
||||
|
||||
let sectionOffset = 0;
|
||||
const points = [];
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const section = sections[0];
|
||||
if (section.type === 'straight') {
|
||||
points.push({
|
||||
x: section.start.x + section.delta.x * sectionOffset,
|
||||
y: section.start.y + section.delta.y * sectionOffset,
|
||||
});
|
||||
} else {
|
||||
points.push(getPointOnCircle(
|
||||
section.center.x,
|
||||
section.center.y,
|
||||
radius,
|
||||
section.startAngle + sectionOffset / radius,
|
||||
));
|
||||
}
|
||||
sectionOffset += spacing;
|
||||
let sectionLength =
|
||||
section.type === 'straight' ? longSide : (TAU / 2) * radius;
|
||||
while (sectionOffset > sectionLength) {
|
||||
sectionOffset -= sectionLength;
|
||||
sections.push(sections.shift());
|
||||
sectionLength =
|
||||
sections[0].type === 'straight' ? longSide : (TAU / 2) * radius;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a numerical value based on the given size parameter.
|
||||
*
|
||||
* @static
|
||||
* @param {string} size - The size style, one of: 's', 'm', 'l', 'xl'.
|
||||
* @return {number} The numerical value corresponding to the given size.
|
||||
* @throws Will default to 130 if the size parameter doesn't match any case.
|
||||
*/
|
||||
static switchSize(size) {
|
||||
switch (size) {
|
||||
case 's':
|
||||
return 50;
|
||||
case 'm':
|
||||
return 70;
|
||||
case 'l':
|
||||
return 100;
|
||||
case 'xl':
|
||||
return 130;
|
||||
default:
|
||||
return 130;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the circumference of a pill shape.
|
||||
*
|
||||
* A pill shape is a rectangle with semi-circular ends. The function
|
||||
* calculates the total distance around the shape using its width and height.
|
||||
*
|
||||
* @static
|
||||
* @param {number} width - The width of the pill shape.
|
||||
* @param {number} height - The height of the pill shape.
|
||||
* @return {number} The circumference of the pill shape.
|
||||
*/
|
||||
static getPillCircumference(width, height) {
|
||||
const radius = Math.min(width, height) / 2;
|
||||
const longSide = Math.max(width, height) - radius * 2;
|
||||
|
||||
return TAU * radius + 2 * longSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get arcs for generating a cloud shape.
|
||||
*
|
||||
* @static
|
||||
* @param {number} width - The width of the cloud.
|
||||
* @param {number} height - The height of the cloud.
|
||||
* @param {string} seed - The random seed for the cloud.
|
||||
* @param {Object} size - The size style for the cloud.
|
||||
* @return {Array} An array of arcs data.
|
||||
*/
|
||||
static getCloudArcs(width, height, seed, size) {
|
||||
const getRandom = rng(seed);
|
||||
|
||||
const pillCircumference = Cloud.getPillCircumference(width, height);
|
||||
|
||||
const numBumps = Math.max(
|
||||
Math.ceil(pillCircumference / Cloud.switchSize(size)),
|
||||
6,
|
||||
Math.ceil(pillCircumference / Math.min(width, height)),
|
||||
);
|
||||
|
||||
const targetBumpProtrusion = (pillCircumference / numBumps) * 0.2;
|
||||
|
||||
const innerWidth = Math.max(width - targetBumpProtrusion * 2, 1);
|
||||
const innerHeight = Math.max(height - targetBumpProtrusion * 2, 1);
|
||||
const paddingX = (width - innerWidth) / 2;
|
||||
const paddingY = (height - innerHeight) / 2;
|
||||
|
||||
const distanceBetweenPointsOnPerimeter =
|
||||
Cloud.getPillCircumference(innerWidth, innerHeight) / numBumps;
|
||||
|
||||
let bumpPoints = Cloud.getPillPoints(innerWidth, innerHeight, numBumps);
|
||||
bumpPoints = bumpPoints.map((p) => {
|
||||
return {
|
||||
x: p.x + paddingX,
|
||||
y: p.y + paddingY,
|
||||
};
|
||||
});
|
||||
|
||||
const maxWiggleX = width < 20 ? 0 : targetBumpProtrusion * 0.3;
|
||||
const maxWiggleY = height < 20 ? 0 : targetBumpProtrusion * 0.3;
|
||||
const wiggledPoints = bumpPoints.slice(0);
|
||||
|
||||
for (let i = 0; i < Math.floor(numBumps / 2); i++) {
|
||||
wiggledPoints[i].x += getRandom() * maxWiggleX;
|
||||
wiggledPoints[i].y += getRandom() * maxWiggleY;
|
||||
wiggledPoints[numBumps - i - 1].x += getRandom() * maxWiggleX;
|
||||
wiggledPoints[numBumps - i - 1].y += getRandom() * maxWiggleY;
|
||||
}
|
||||
|
||||
const arcs = [];
|
||||
|
||||
for (let i = 0; i < wiggledPoints.length; i++) {
|
||||
const j = i === wiggledPoints.length - 1 ? 0 : i + 1;
|
||||
const leftWigglePoint = wiggledPoints[i];
|
||||
const rightWigglePoint = wiggledPoints[j];
|
||||
const leftPoint = bumpPoints[i];
|
||||
const rightPoint = bumpPoints[j];
|
||||
|
||||
const midPoint = {
|
||||
x: (leftPoint.x + rightPoint.x) / 2,
|
||||
y: (leftPoint.y + rightPoint.y) / 2,
|
||||
};
|
||||
|
||||
const offsetAngle = Math.atan2(rightPoint.y - leftPoint.y,
|
||||
rightPoint.x - leftPoint.x) - TAU;
|
||||
|
||||
const distanceBetweenOriginalPoints =
|
||||
Math.sqrt(Math.pow(rightPoint.x - leftPoint.x, 2) +
|
||||
Math.pow(rightPoint.y - leftPoint.y, 2));
|
||||
|
||||
const curvatureOffset =
|
||||
distanceBetweenPointsOnPerimeter - distanceBetweenOriginalPoints;
|
||||
|
||||
const distanceBetweenWigglePoints =
|
||||
Math.sqrt(Math.pow(rightWigglePoint.x - leftWigglePoint.x, 2) +
|
||||
Math.pow(rightWigglePoint.y - leftWigglePoint.y, 2));
|
||||
|
||||
const relativeSize =
|
||||
distanceBetweenWigglePoints / distanceBetweenOriginalPoints;
|
||||
const finalDistance = (Math.max(paddingX, paddingY) +
|
||||
curvatureOffset) * relativeSize;
|
||||
|
||||
const arcPoint = {
|
||||
x: midPoint.x + Math.cos(offsetAngle) * finalDistance,
|
||||
y: midPoint.y + Math.sin(offsetAngle) * finalDistance,
|
||||
};
|
||||
|
||||
arcPoint.x = Math.min(Math.max(arcPoint.x, 0), width);
|
||||
arcPoint.y = Math.min(Math.max(arcPoint.y, 0), height);
|
||||
|
||||
arcs.push({
|
||||
leftPoint: leftWigglePoint,
|
||||
rightPoint: rightWigglePoint,
|
||||
arcPoint,
|
||||
});
|
||||
}
|
||||
|
||||
return arcs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an SVG path string to represent a cloud shape using arc segments.
|
||||
*
|
||||
* @param {number} width - The width of the cloud.
|
||||
* @param {number} height - The height of the cloud.
|
||||
* @param {number} seed - The seed value for randomization (if applicable).
|
||||
* @param {number} size - The size of the cloud.
|
||||
* @return {string} - An SVG path string representing the cloud.
|
||||
*/
|
||||
static cloudSvgPath(width, height, seed, size) {
|
||||
// Get cloud arcs based on input parameters
|
||||
const arcs = Cloud.getCloudArcs(width, height, seed, size);
|
||||
|
||||
// Initialize SVG path starting with the 'M' command
|
||||
// for the first arc's leftPoint
|
||||
const initialX = arcs[0].leftPoint.x.toFixed(2);
|
||||
const initialY = arcs[0].leftPoint.y.toFixed(2);
|
||||
let path = `M${initialX},${initialY}`;
|
||||
|
||||
// Loop through all arcs to construct the 'A' commands for the SVG path
|
||||
for (const {leftPoint, rightPoint, arcPoint} of arcs) {
|
||||
// Approximate radius through heuristic, as determining the true
|
||||
// radius from the circle formed by the three points proved numerically
|
||||
// unstable.
|
||||
const radius = calculateDistance(leftPoint, arcPoint).toFixed(2);
|
||||
|
||||
const endPointX = rightPoint.x.toFixed(2);
|
||||
const endPointY = rightPoint.y.toFixed(2);
|
||||
|
||||
path += `A${radius},${radius} 0 0, 1 ${endPointX},${endPointY}`;
|
||||
}
|
||||
|
||||
// Close the SVG path with 'Z'
|
||||
path += ' Z';
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a cloud shape on the SVG canvas. It uses a predefined SVG path
|
||||
* for the cloud shape, which is scaled to the dimensions of the instance.
|
||||
* @return {G} An SVG group element (`<g>`)
|
||||
* that contains the cloud path and label.
|
||||
*/
|
||||
draw() {
|
||||
const points = Cloud.cloudSvgPath(
|
||||
this.w,
|
||||
this.h + this.growY,
|
||||
this.id,
|
||||
this.size);
|
||||
|
||||
const cloudGroup = this.shapeGroup;
|
||||
const cloud = new Path({
|
||||
'd': points,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
this.setFill(cloud);
|
||||
cloudGroup.add(cloud);
|
||||
this.drawLabel(cloudGroup);
|
||||
|
||||
return cloudGroup;
|
||||
}
|
||||
}
|
43
bbb-export-annotations/shapes/Diamond.js
Normal file
43
bbb-export-annotations/shapes/Diamond.js
Normal file
@ -0,0 +1,43 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG diamond shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Diamond
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Diamond extends Geo {
|
||||
/**
|
||||
* Draws a diamond shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the diamond.
|
||||
*/
|
||||
draw() {
|
||||
const width = this.w;
|
||||
const height = this.h + this.growY;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
|
||||
// Shape begins from the upper left corner
|
||||
const points = [
|
||||
[0, halfHeight],
|
||||
[halfWidth, 0],
|
||||
[width, halfHeight],
|
||||
[halfWidth, height],
|
||||
].map((p) => p.join(',')).join(' ');
|
||||
|
||||
const diamondGroup = this.shapeGroup;
|
||||
const diamond = new SVGPolygon({
|
||||
points,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
this.setFill(diamond);
|
||||
diamondGroup.add(diamond);
|
||||
this.drawLabel(diamondGroup);
|
||||
|
||||
return diamondGroup;
|
||||
}
|
||||
}
|
126
bbb-export-annotations/shapes/Draw.js
Normal file
126
bbb-export-annotations/shapes/Draw.js
Normal file
@ -0,0 +1,126 @@
|
||||
import pkg from 'perfect-freehand';
|
||||
import {TAU} from '../shapes/helpers.js';
|
||||
import {Path} from '@svgdotjs/svg.js';
|
||||
import {Shape} from './Shape.js';
|
||||
|
||||
const {getStrokePoints, getStrokeOutlinePoints} = pkg;
|
||||
|
||||
/**
|
||||
* Creates an SVG path from Tldraw v2 pencil data.
|
||||
*
|
||||
* @class Draw
|
||||
* @extends {Shape}
|
||||
*/
|
||||
export class Draw extends Shape {
|
||||
/**
|
||||
* @param {Object} draw - The draw shape JSON.
|
||||
*/
|
||||
constructor(draw) {
|
||||
super(draw);
|
||||
this.segments = this.props?.segments;
|
||||
this.isClosed = this.props?.isClosed;
|
||||
this.isComplete = this.props?.isComplete;
|
||||
}
|
||||
|
||||
static simulatePressure = {
|
||||
easing: (t) => Math.sin((t * TAU) / 4),
|
||||
simulatePressure: true,
|
||||
};
|
||||
|
||||
static realPressure = {
|
||||
easing: (t) => t * t,
|
||||
simulatePressure: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns an array of points into a path of quadradic curves.
|
||||
* @param {Array} annotationPoints
|
||||
* @param {Boolean} closed - whether the path end and start
|
||||
* should be connected (default)
|
||||
* @return {Array} - an SVG quadratic curve path
|
||||
*/
|
||||
getSvgPath(annotationPoints, closed = true) {
|
||||
const svgPath = annotationPoints.reduce(
|
||||
(acc, [x0, y0], i, arr) => {
|
||||
if (!arr[i + 1]) return acc;
|
||||
const [x1, y1] = arr[i + 1];
|
||||
acc.push(x0.toFixed(2), y0.toFixed(2),
|
||||
((x0 + x1) / 2).toFixed(2),
|
||||
((y0 + y1) / 2).toFixed(2));
|
||||
return acc;
|
||||
},
|
||||
|
||||
['M', ...annotationPoints[0], 'Q'],
|
||||
);
|
||||
|
||||
if (closed) svgPath.push('Z');
|
||||
return svgPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the draw object as an SVG group element.
|
||||
*
|
||||
* @return {G} - An SVG group element.
|
||||
*/
|
||||
draw() {
|
||||
const shapePoints = this.segments[0]?.points;
|
||||
const shapePointsLength = shapePoints?.length || 0;
|
||||
const isDashDraw = (this.dash === 'draw');
|
||||
const drawGroup = this.shapeGroup;
|
||||
|
||||
const options = {
|
||||
size: 1 + this.thickness * 1.5,
|
||||
thinning: 0.65,
|
||||
streamline: 0.65,
|
||||
smoothing: 0.65,
|
||||
...(shapePoints[1]?.z === 0.5 ?
|
||||
this.simulatePressure : this.realPressure),
|
||||
last: this.isComplete,
|
||||
};
|
||||
|
||||
const strokePoints = getStrokePoints(shapePoints, options);
|
||||
|
||||
const drawPath = new Path();
|
||||
const fillShape = new Path();
|
||||
|
||||
const last = shapePoints[shapePointsLength - 1];
|
||||
|
||||
// Avoid single dots from not being drawn
|
||||
if (strokePoints[0].point[0] == last[0] &&
|
||||
strokePoints[0].point[1] == last[1]) {
|
||||
strokePoints.push({point: last});
|
||||
}
|
||||
|
||||
const solidPath = strokePoints.map((strokePoint) => strokePoint.point);
|
||||
const svgPath = this.getSvgPath(solidPath, this.isClosed);
|
||||
|
||||
fillShape.attr('d', svgPath);
|
||||
|
||||
// In case the background shape is the shape itself, add the stroke to it
|
||||
if (!isDashDraw) {
|
||||
fillShape.attr('stroke', this.shapeColor);
|
||||
fillShape.attr('stroke-width', this.thickness);
|
||||
fillShape.attr('style', this.dasharray);
|
||||
}
|
||||
|
||||
// Fill only applies for closed shapes
|
||||
if (!this.isClosed) {
|
||||
this.fill = 'none';
|
||||
}
|
||||
|
||||
this.setFill(fillShape);
|
||||
|
||||
if (isDashDraw) {
|
||||
const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options);
|
||||
const svgPath = this.getSvgPath(strokeOutlinePoints);
|
||||
|
||||
drawPath.attr('fill', this.shapeColor);
|
||||
drawPath.attr('d', svgPath);
|
||||
}
|
||||
|
||||
drawGroup.add(fillShape);
|
||||
drawGroup.add(drawPath);
|
||||
|
||||
return drawGroup;
|
||||
}
|
||||
}
|
36
bbb-export-annotations/shapes/Ellipse.js
Normal file
36
bbb-export-annotations/shapes/Ellipse.js
Normal file
@ -0,0 +1,36 @@
|
||||
import {Ellipse as SVGEllipse} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG ellipse shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Ellipse
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Ellipse extends Geo {
|
||||
/**
|
||||
* Draws an ellipse shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the ellipse.
|
||||
*/
|
||||
draw() {
|
||||
const rx = this.w / 2;
|
||||
const ry = (this.h + this.growY) / 2;
|
||||
|
||||
const ellipseGroup = this.shapeGroup;
|
||||
const ellipse = new SVGEllipse({
|
||||
'cx': rx.toFixed(2),
|
||||
'cy': ry.toFixed(2),
|
||||
'rx': rx.toFixed(2),
|
||||
'ry': ry.toFixed(2),
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
this.setFill(ellipse);
|
||||
ellipseGroup.add(ellipse);
|
||||
this.drawLabel(ellipseGroup);
|
||||
|
||||
return ellipseGroup;
|
||||
}
|
||||
}
|
99
bbb-export-annotations/shapes/Frame.js
Normal file
99
bbb-export-annotations/shapes/Frame.js
Normal file
@ -0,0 +1,99 @@
|
||||
import {Shape} from './Shape.js';
|
||||
import {Rect, Text, ClipPath, Defs, G} from '@svgdotjs/svg.js';
|
||||
import {ColorTypes} from '../shapes/Shape.js';
|
||||
import {overlayAnnotation} from '../workers/process.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG frame from Tldraw v2 pencil data.
|
||||
*
|
||||
* @class Frane
|
||||
* @extends {Shape}
|
||||
*/
|
||||
export class Frame extends Shape {
|
||||
/**
|
||||
* @param {Object} frame - The Frame shape JSON.
|
||||
*/
|
||||
constructor(frame) {
|
||||
super(frame);
|
||||
this.name = this.props?.name;
|
||||
this.w = this.props?.w;
|
||||
this.h = this.props?.h;
|
||||
this.children = frame.children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the frame object as an SVG group element.
|
||||
*
|
||||
* @return {G} - An SVG group element.
|
||||
*/
|
||||
draw() {
|
||||
// Parent group
|
||||
const frameGroup = this.shapeGroup;
|
||||
|
||||
// Group for clipped elements
|
||||
const clipGroup = new G();
|
||||
|
||||
const fillColor = Shape.colorToHex(ColorTypes.SemiFillColor,
|
||||
ColorTypes.SemiFillColor);
|
||||
|
||||
const frameLabel = this.name || 'Frame';
|
||||
|
||||
// The text element is not clipped
|
||||
const textElement = new Text()
|
||||
.text(frameLabel)
|
||||
.move(0, -20)
|
||||
.font({
|
||||
'family': 'Arial',
|
||||
'size': 12,
|
||||
})
|
||||
.fill('black');
|
||||
|
||||
// The frame rectangle that is not clipped
|
||||
const frame = new Rect({
|
||||
'x': 0,
|
||||
'y': 0,
|
||||
'width': this.w,
|
||||
'height': this.h,
|
||||
'stroke': 'black',
|
||||
'stroke-width': 1,
|
||||
'fill': fillColor,
|
||||
});
|
||||
|
||||
// Create the clip path with the same properties as the frame
|
||||
const clipPath = new ClipPath().id(`clipFrame-${this.id}`);
|
||||
const clipFrame = new Rect({
|
||||
'x': 0,
|
||||
'y': 0,
|
||||
'width': this.w,
|
||||
'height': this.h,
|
||||
});
|
||||
|
||||
clipPath.add(clipFrame);
|
||||
|
||||
// Definitions for clip paths
|
||||
const defs = new Defs();
|
||||
defs.add(clipPath);
|
||||
|
||||
// Add defs to the parent group
|
||||
frameGroup.add(defs);
|
||||
|
||||
const children = this.children || [];
|
||||
|
||||
// Add the children to the clipGroup so they will be clipped
|
||||
children.forEach((child) => {
|
||||
overlayAnnotation(clipGroup, child);
|
||||
});
|
||||
|
||||
// Apply clipping to the clipGroup only
|
||||
clipGroup.clipWith(clipPath);
|
||||
|
||||
// Add non-clipped...
|
||||
frameGroup.add(frame);
|
||||
frameGroup.add(textElement);
|
||||
|
||||
// ...and clipped elements to the frame group
|
||||
frameGroup.add(clipGroup);
|
||||
|
||||
return frameGroup;
|
||||
}
|
||||
}
|
65
bbb-export-annotations/shapes/Geo.js
Normal file
65
bbb-export-annotations/shapes/Geo.js
Normal file
@ -0,0 +1,65 @@
|
||||
import {Shape} from './Shape.js';
|
||||
import {TAU} from './helpers.js';
|
||||
/**
|
||||
* Class representing geometric shapes.
|
||||
*
|
||||
* @class Geo
|
||||
* @extends {Shape}
|
||||
*/
|
||||
export class Geo extends Shape {
|
||||
/**
|
||||
* Creates an instance of Geo.
|
||||
*
|
||||
* @param {Object} geo - JSON containing geometric shape properties.
|
||||
*/
|
||||
constructor(geo) {
|
||||
super(geo);
|
||||
this.url = this.props?.url;
|
||||
this.font = this.props?.font;
|
||||
this.w = this.props?.w;
|
||||
this.h = this.props?.h;
|
||||
this.growY = this.props?.growY;
|
||||
this.align = this.props?.align;
|
||||
this.geo = this.props?.geo;
|
||||
this.verticalAlign = this.props?.verticalAlign;
|
||||
this.labelColor = this.props?.labelColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the vertices of a polygon given its dimensions and the number of sides.
|
||||
* @param {number} width The width of the bounding box for the polygon.
|
||||
* @param {number} height The height of the bounding box for the polygon.
|
||||
* @param {number} sides The number of sides for the polygon.
|
||||
* @return {Array} An array of objects with x and y coordinates for each vertex.
|
||||
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/primitives/utils.ts} Adapted from Tldraw.
|
||||
*/
|
||||
static getPolygonVertices(width, height, sides) {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const pointsOnPerimeter = [];
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const step = TAU / sides;
|
||||
const t = -(TAU / 4) + i * step;
|
||||
const x = cx + cx * Math.cos(t);
|
||||
const y = cy + cy * Math.sin(t);
|
||||
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
|
||||
pointsOnPerimeter.push({x, y});
|
||||
}
|
||||
|
||||
if (minX !== 0 || minY !== 0) {
|
||||
for (let i = 0; i < pointsOnPerimeter.length; i++) {
|
||||
const pt = pointsOnPerimeter[i];
|
||||
pt.x -= minX;
|
||||
pt.y -= minY;
|
||||
}
|
||||
}
|
||||
|
||||
return pointsOnPerimeter;
|
||||
}
|
||||
}
|
42
bbb-export-annotations/shapes/Hexagon.js
Normal file
42
bbb-export-annotations/shapes/Hexagon.js
Normal file
@ -0,0 +1,42 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG hexagon shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Hexagon
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Hexagon extends Geo {
|
||||
/**
|
||||
* Draws a hexagon shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the hexagon.
|
||||
*/
|
||||
draw() {
|
||||
const width = this.w;
|
||||
const height = this.h + this.growY;
|
||||
const sides = 6;
|
||||
|
||||
// Get the vertices of the hexagon
|
||||
const pointsOnPerimeter = Geo.getPolygonVertices(width, height, sides);
|
||||
|
||||
// Convert the vertices to SVG polygon points format
|
||||
const points = pointsOnPerimeter.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
|
||||
// Create the SVG polygon
|
||||
const hexagonGroup = this.shapeGroup;
|
||||
const hexagon = new SVGPolygon({
|
||||
points,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
// Fill the polygon if required
|
||||
this.setFill(hexagon);
|
||||
hexagonGroup.add(hexagon);
|
||||
this.drawLabel(hexagonGroup);
|
||||
|
||||
return hexagonGroup;
|
||||
}
|
||||
}
|
23
bbb-export-annotations/shapes/Highlight.js
Normal file
23
bbb-export-annotations/shapes/Highlight.js
Normal file
@ -0,0 +1,23 @@
|
||||
import {Draw} from './Draw.js';
|
||||
|
||||
/**
|
||||
* Represents a Highlight shape, extending the functionality of the Draw class.
|
||||
*
|
||||
* @class Highlight
|
||||
* @extends {Draw}
|
||||
*/
|
||||
export class Highlight extends Draw {
|
||||
/**
|
||||
* Creates an instance of the Highlight class.
|
||||
*
|
||||
* @param {Object} highlight - The highlighter's JSON data.
|
||||
*/
|
||||
constructor(highlight) {
|
||||
super(highlight);
|
||||
|
||||
this.fill = 'none';
|
||||
this.shapeColor = '#fedd00';
|
||||
this.thickness = this.thickness * 7;
|
||||
this.isClosed = false;
|
||||
}
|
||||
}
|
81
bbb-export-annotations/shapes/Line.js
Normal file
81
bbb-export-annotations/shapes/Line.js
Normal file
@ -0,0 +1,81 @@
|
||||
import {Path} from '@svgdotjs/svg.js';
|
||||
import {Shape} from './Shape.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG path from Tldraw v2 line data.
|
||||
*
|
||||
* @class Line
|
||||
* @extends {Shape}
|
||||
*/
|
||||
export class Line extends Shape {
|
||||
/**
|
||||
* @param {Object} line - The line shape JSON.
|
||||
*/
|
||||
constructor(line) {
|
||||
super(line);
|
||||
this.handles = this.props?.handles;
|
||||
this.spline = this.props?.spline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the line type (spline, line), constructs the SVG path.
|
||||
*
|
||||
* @param {Object} handles - The vertex points and control points.
|
||||
* @param {string} spline - The type of spline ('cubic' or 'line').
|
||||
* @return {string} - The SVG path data.
|
||||
*/
|
||||
constructPath(handles, spline) {
|
||||
const start = handles.start;
|
||||
const end = handles.end;
|
||||
const ctl = handles['handle:a1V'];
|
||||
let path = `M${start.x},${start.y} `;
|
||||
|
||||
if (spline === 'cubic' && ctl) {
|
||||
// Compute adjusted control points to make curve pass through `ctl`
|
||||
const t = 0.5; // Assumes the curve passes through `ctl` at t = 0.5
|
||||
const b = {
|
||||
x: (ctl.x - (1-t) ** 3 * start.x - t ** 3 * end.x) / (3 * (1-t) * t),
|
||||
y: (ctl.y - (1-t) ** 3 * start.y - t ** 3 * end.y) / (3 * (1-t) * t),
|
||||
};
|
||||
const c = {
|
||||
x: (ctl.x - (1-t) ** 3 * start.x - t ** 3 * end.x) / (3 * (1-t) * t),
|
||||
y: (ctl.y - (1-t) ** 3 * start.y - t ** 3 * end.y) / (3 * (1-t) * t),
|
||||
};
|
||||
// Draw cubic spline
|
||||
path += `C${b.x},${b.y} ${c.x},${c.y} ${end.x},${end.y}`;
|
||||
} else if (spline === 'line' && ctl) {
|
||||
// Draw straight lines to and from control point
|
||||
path += `L${ctl.x},${ctl.y} L${end.x},${end.y}`;
|
||||
} else {
|
||||
// Draw straight line
|
||||
path += `L${end.x},${end.y}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the line object as an SVG group element.
|
||||
*
|
||||
* @return {G} - An SVG group element.
|
||||
*/
|
||||
draw() {
|
||||
const lineGroup = this.shapeGroup;
|
||||
const linePath = new Path();
|
||||
|
||||
const svgPath = this.constructPath(this.handles, this.spline);
|
||||
|
||||
linePath.attr({
|
||||
'd': svgPath,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
'fill': 'none',
|
||||
});
|
||||
|
||||
lineGroup.add(linePath);
|
||||
this.drawLabel(lineGroup);
|
||||
|
||||
return lineGroup;
|
||||
}
|
||||
}
|
60
bbb-export-annotations/shapes/Oval.js
Normal file
60
bbb-export-annotations/shapes/Oval.js
Normal file
@ -0,0 +1,60 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
import {TAU} from '../shapes/helpers.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG oval shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Oval
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Oval extends Geo {
|
||||
/**
|
||||
* Draws an oval shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the oval.
|
||||
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/primitives/geometry/Stadium2d.ts} Adapted from Tldraw.
|
||||
*/
|
||||
draw() {
|
||||
const w = Math.max(1, this.w);
|
||||
const h = Math.max(1, this.h + this.growY);
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
|
||||
const len = 25; // Number of vertices to use for the oval
|
||||
const points = Array(len * 2 - 2).fill(null).map(() => []);
|
||||
|
||||
if (h > w) {
|
||||
for (let i = 0; i < len - 1; i++) {
|
||||
const t1 = -(TAU / 2) + ((TAU / 2) * i) / (len - 2);
|
||||
const t2 = ((TAU / 2) * i) / (len - 2);
|
||||
points[i] = [cx + cx * Math.cos(t1), cx + cx * Math.sin(t1)];
|
||||
points[i + (len - 1)] = [cx + cx * Math.cos(t2),
|
||||
h - cx + cx * Math.sin(t2)];
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < len - 1; i++) {
|
||||
const t1 = -(TAU / 4) + (TAU / 2 * i) / (len - 2);
|
||||
const t2 = (TAU / 4) + (TAU / 2 * -i) / (len - 2);
|
||||
points[i] = [w - cy + cy * Math.cos(t1), h - cy + cy * Math.sin(t1)];
|
||||
points[i + (len - 1)] = [cy - cy * Math.cos(t2),
|
||||
h - cy + cy * Math.sin(t2)];
|
||||
}
|
||||
}
|
||||
|
||||
const formattedPoints = points.map((p) => p.join(',')).join(' ');
|
||||
|
||||
const ovalGroup = this.shapeGroup;
|
||||
const oval = new SVGPolygon({
|
||||
'points': formattedPoints,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
this.setFill(oval);
|
||||
ovalGroup.add(oval);
|
||||
this.drawLabel(ovalGroup);
|
||||
|
||||
return ovalGroup;
|
||||
}
|
||||
}
|
43
bbb-export-annotations/shapes/Rectangle.js
Normal file
43
bbb-export-annotations/shapes/Rectangle.js
Normal file
@ -0,0 +1,43 @@
|
||||
import {Rect} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG rectangle shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Rectangle
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Rectangle extends Geo {
|
||||
/**
|
||||
* Draws a rectangle shape based on the instance properties.
|
||||
*
|
||||
* @method draw
|
||||
* @return {G} An SVG group element containing the drawn rectangle shape.
|
||||
*
|
||||
*/
|
||||
draw() {
|
||||
const rectGroup = this.shapeGroup;
|
||||
|
||||
const rectangle = new Rect({
|
||||
'x': 0,
|
||||
'y': 0,
|
||||
'width': this.w,
|
||||
'height': this.h + this.growY,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
// Simulate perfect-freehand effect
|
||||
if (this.dash === 'draw') {
|
||||
rectangle.attr('rx', this.thickness);
|
||||
rectangle.attr('ry', this.thickness);
|
||||
}
|
||||
|
||||
this.setFill(rectangle);
|
||||
rectGroup.add(rectangle);
|
||||
this.drawLabel(rectGroup);
|
||||
|
||||
return rectGroup;
|
||||
}
|
||||
}
|
44
bbb-export-annotations/shapes/Rhombus.js
Normal file
44
bbb-export-annotations/shapes/Rhombus.js
Normal file
@ -0,0 +1,44 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG rhombus shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Rhombus
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Rhombus extends Geo {
|
||||
/**
|
||||
* Draws a rhombus shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the rhombus.
|
||||
*/
|
||||
draw() {
|
||||
const width = this.w;
|
||||
const height = this.h + this.growY;
|
||||
|
||||
// Internal angle between adjacent sides varies with width and height
|
||||
const offset = Math.min(width * 0.38, height * 0.38);
|
||||
|
||||
// Coordinates for the four vertices of the rhombus
|
||||
const points = [
|
||||
[offset, 0], // Top left vertex
|
||||
[width, 0], // Top right vertex
|
||||
[width - offset, height], // Bottom right vertex
|
||||
[0, height], // Bottom left vertex
|
||||
].map((p) => p.join(',')).join(' ');
|
||||
|
||||
const rhombusGroup = this.shapeGroup;
|
||||
const rhombus = new SVGPolygon({
|
||||
points,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
this.setFill(rhombus);
|
||||
rhombusGroup.add(rhombus);
|
||||
this.drawLabel(rhombusGroup);
|
||||
|
||||
return rhombusGroup;
|
||||
}
|
||||
}
|
507
bbb-export-annotations/shapes/Shape.js
Normal file
507
bbb-export-annotations/shapes/Shape.js
Normal file
@ -0,0 +1,507 @@
|
||||
import {Pattern, Line, Defs, Rect, G, Text, Tspan} from '@svgdotjs/svg.js';
|
||||
import {radToDegree} from '../shapes/helpers.js';
|
||||
import opentype from 'opentype.js';
|
||||
import fs from 'fs';
|
||||
/**
|
||||
* Represents a basic Tldraw shape on the whiteboard.
|
||||
*
|
||||
* @class Shape
|
||||
* @typedef {Object} ColorTypes
|
||||
* @property {'shape'} ShapeColor - Color for shape outlines or borders.
|
||||
* @property {'fill'} FillColor - Solid fill color inside the shape.
|
||||
* @property {'semi'} SemiFillColor - Semi fill shape color.
|
||||
* @property {'sticky'} StickyColor - Color for sticky notes.
|
||||
*/
|
||||
export class Shape {
|
||||
/**
|
||||
* Creates an instance of Shape.
|
||||
* @constructor
|
||||
* @param {Object} params - The shape's parameters.
|
||||
* @param {String} params.id - The the shape ID.
|
||||
* @param {Number} params.x - The shape's x-coordinate.
|
||||
* @param {Number} params.y - The shape's y-coordinate.
|
||||
* @param {Number} params.rotation - The shape's rotation angle in radians.
|
||||
* @param {Number} params.opacity - The shape's opacity.
|
||||
* @param {Object} params.props - Shape-specific properties.
|
||||
*/
|
||||
constructor({
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
opacity,
|
||||
props,
|
||||
}) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.rotation = rotation;
|
||||
this.opacity = opacity;
|
||||
this.props = props;
|
||||
|
||||
this.size = this.props?.size;
|
||||
this.color = this.props?.color;
|
||||
this.dash = this.props?.dash;
|
||||
this.fill = this.props?.fill;
|
||||
this.text = this.props?.text;
|
||||
|
||||
// Derived SVG properties
|
||||
this.thickness = Shape.getStrokeWidth(this.size);
|
||||
this.dasharray = Shape.determineDasharray(this.dash, this.size);
|
||||
this.shapeColor = Shape.colorToHex(this.color, ColorTypes.ShapeColor);
|
||||
|
||||
// SVG representation
|
||||
this.shapeGroup = new G({
|
||||
transform: this.getTransform(),
|
||||
opacity: this.opacity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an SVG <defs> element with a pattern for filling the shape.
|
||||
*
|
||||
* @method getFillPattern
|
||||
* @param {String} shapeColor - The color to use for the pattern lines.
|
||||
* @return {Defs} An SVG <defs> element containing the pattern.
|
||||
*/
|
||||
getFillPattern(shapeColor) {
|
||||
const defs = new Defs();
|
||||
const pattern = new Pattern({
|
||||
id: `hash_pattern-${this.id}`,
|
||||
width: 8,
|
||||
height: 8,
|
||||
patternUnits: 'userSpaceOnUse',
|
||||
patternTransform: 'rotate(45 0 0)',
|
||||
});
|
||||
|
||||
pattern.add(new Rect({width: 8, height: 8, fill: 'white'}));
|
||||
pattern.add(new Line({'x1': 0, 'y1': 0, 'x2': 0, 'y2': 8,
|
||||
'stroke': shapeColor, 'stroke-width': 3.5,
|
||||
'stroke-dasharray': '4, 4'}));
|
||||
|
||||
defs.add(pattern);
|
||||
return defs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the appropriate fill style to the given SVG shape element based on
|
||||
* the object's `fill` property. It supports 'solid', 'semi', 'pattern', and
|
||||
* 'none' as fill options.
|
||||
* @param {SVGElement} shape - The element to be filled
|
||||
*/
|
||||
setFill(shape) {
|
||||
switch (this.fill) {
|
||||
case 'solid':
|
||||
const fillColor = Shape.colorToHex(this.color, ColorTypes.FillColor);
|
||||
shape.attr('fill', fillColor);
|
||||
break;
|
||||
|
||||
case 'semi':
|
||||
const semiColor = Shape.colorToHex(this.fill, ColorTypes.SemiFillColor);
|
||||
shape.attr('fill', semiColor);
|
||||
break;
|
||||
|
||||
case 'pattern':
|
||||
const shapeColor = Shape.colorToHex(this.color, ColorTypes.ShapeColor);
|
||||
const pattern = this.getFillPattern(shapeColor);
|
||||
this.shapeGroup.add(pattern);
|
||||
shape.attr('fill', `url(#hash_pattern-${this.id})`);
|
||||
break;
|
||||
|
||||
default:
|
||||
shape.attr('fill', 'none');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a transformation string for SVG elements based on the object's
|
||||
* rotation and position properties. The transformation includes translation,
|
||||
* rotation, and setting the transform origin to the center.
|
||||
*
|
||||
* @return {string} The SVG transform attribute value.
|
||||
*/
|
||||
getTransform() {
|
||||
const x = this.x.toFixed(2);
|
||||
const y = this.y.toFixed(2);
|
||||
const rotation = radToDegree(this.rotation);
|
||||
const translate = `translate(${x} ${y})`;
|
||||
const transformOrigin = 'transform-origin: center';
|
||||
const rotate = `rotate(${rotation})`;
|
||||
const transform = `${translate}; ${transformOrigin}; ${rotate}`;
|
||||
|
||||
return transform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a tldraw color name to its corresponding HEX code.
|
||||
*
|
||||
* @param {string} color - The name of the color (e.g., 'blue', 'red').
|
||||
* @param {string} colorType - Context to select the appropriate mapping.
|
||||
* Valid values are 'shape', 'fill',
|
||||
* 'semi', and 'sticky'.
|
||||
*
|
||||
* @return {string} The HEX code for the given color and color type.
|
||||
* Returns '#0d0d0d' if not found.
|
||||
*/
|
||||
static colorToHex(color, colorType) {
|
||||
const colorMap = {
|
||||
'black': '#161616',
|
||||
'grey': '#9EA6B0',
|
||||
'light-violet': '#DD80F5',
|
||||
'violet': '#9C1FBE',
|
||||
'blue': '#3348E5',
|
||||
'light-blue': '#4099F5',
|
||||
'yellow': '#FDB365',
|
||||
'orange': '#F3500B',
|
||||
'green': '#148355',
|
||||
'light-green': '#38B845',
|
||||
'light-red': '#FC7075',
|
||||
'red': '#D61A25',
|
||||
};
|
||||
|
||||
const fillMap = {
|
||||
'black': '#E2E2E2',
|
||||
'grey': '#E7EAEC',
|
||||
'light-violet': '#F2E5F9',
|
||||
'violet': '#E7D3EF',
|
||||
'blue': '#D4D8F6',
|
||||
'light-blue': '#D6E8F9',
|
||||
'yellow': '#F8ECE0',
|
||||
'orange': '#F5DBCA',
|
||||
'green': '#CAE5DC',
|
||||
'light-green': '#D4EED9',
|
||||
'light-red': '#F0D1D3',
|
||||
'red': '#F0D1D3',
|
||||
};
|
||||
|
||||
const stickyMap = {
|
||||
'black': '#FEC78C',
|
||||
'grey': '#B6BDC3',
|
||||
'light-violet': '#E4A1F7',
|
||||
'violet': '#B65ACF',
|
||||
'blue': '#6476EC',
|
||||
'light-blue': '#6FB3F6',
|
||||
'yellow': '#FEC78C',
|
||||
'orange': '#F57D48',
|
||||
'green': '#47A37F',
|
||||
'light-green': '#64C46F',
|
||||
'light-red': '#FC9598',
|
||||
'red': '#E05458',
|
||||
};
|
||||
|
||||
const semiFillMap = {
|
||||
'semi': '#F5F9F7',
|
||||
};
|
||||
|
||||
const colors = {
|
||||
shape: colorMap,
|
||||
fill: fillMap,
|
||||
semi: semiFillMap,
|
||||
sticky: stickyMap,
|
||||
};
|
||||
|
||||
return colors[colorType][color] || '#0d0d0d';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines SVG style attributes based on the dash type.
|
||||
*
|
||||
* @param {string} dash - The type of dash ('dashed', 'dotted').
|
||||
* @param {string} size - The size ('s', 'm', 'l', 'xl').
|
||||
*
|
||||
* @return {string} A string representing the SVG attributes
|
||||
* for the given dash and gap.
|
||||
*/
|
||||
static determineDasharray(dash, size) {
|
||||
const gapSettings = {
|
||||
'dashed': {
|
||||
's': '4.37 4.91',
|
||||
'm': '8.16 10.21',
|
||||
'l': '11.85 14.81',
|
||||
'xl': '21.41 32.12',
|
||||
'default': '8 8',
|
||||
},
|
||||
|
||||
'dotted': {
|
||||
's': '0.02 4',
|
||||
'm': '0.03 8',
|
||||
'l': '0.05 12',
|
||||
'xl': '0.12 16',
|
||||
'default': '0.03 8',
|
||||
},
|
||||
};
|
||||
|
||||
const gap = gapSettings[dash]?.[size] ||
|
||||
gapSettings[dash]?.['default'] ||
|
||||
'0';
|
||||
|
||||
const dashSettings = {
|
||||
'dashed': `stroke-linecap:butt;stroke-dasharray:${gap};`,
|
||||
'dotted': `stroke-linecap:round;stroke-dasharray:${gap};`,
|
||||
};
|
||||
|
||||
return dashSettings[dash] || 'stroke-linejoin:round;stroke-linecap:round;';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stroke width based on the size.
|
||||
*
|
||||
* @param {string} size - The size of the stroke ('s', 'm', 'l', 'xl').
|
||||
* @return {number} - The corresponding stroke width.
|
||||
*/
|
||||
static getStrokeWidth(size) {
|
||||
const strokeWidths = {
|
||||
's': 2,
|
||||
'm': 3.5,
|
||||
'l': 5,
|
||||
'xl': 7.5,
|
||||
};
|
||||
|
||||
return strokeWidths[size] || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the font size in pixels.
|
||||
*
|
||||
* @param {string} size - The size of the font ('s', 'm', 'l', 'xl').
|
||||
* @return {number} - The corresponding font size, in pixels.
|
||||
*/
|
||||
static determineFontSize(size) {
|
||||
const fontSizes = {
|
||||
's': 26,
|
||||
'm': 36,
|
||||
'l': 54,
|
||||
'xl': 64,
|
||||
};
|
||||
|
||||
return fontSizes[size] || 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns horizontally based on the given alignment type.
|
||||
*
|
||||
* @param {string} align - One of ('start', 'middle', 'end').
|
||||
* @param {number} width - The width of the container.
|
||||
* @return {string} The calculated horizontal position as a string with
|
||||
* two decimal places. Coordinates are relative to the container.
|
||||
* @static
|
||||
*/
|
||||
static alignHorizontally(align, width) {
|
||||
switch (align) {
|
||||
case 'middle': return (width / 2).toFixed(2);
|
||||
case 'end': return (width).toFixed(2);
|
||||
default: return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns vertically based on the given alignment type.
|
||||
*
|
||||
* @param {string} align - One of ('start', 'middle', 'end').
|
||||
* @param {number} height - The height of the container.
|
||||
* @return {string} The calculated vertical position as a string with
|
||||
* two decimal places. Coordinates are relative to the container.
|
||||
* @static
|
||||
*/
|
||||
static alignVertically(align, height) {
|
||||
switch (align) {
|
||||
case 'middle': return (height / 2).toFixed(2);
|
||||
case 'end': return height.toFixed(2);
|
||||
default: return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the font to use based on the specified font family.
|
||||
* Supported families are 'draw', 'sans', 'serif', and 'mono'. Any other input
|
||||
* defaults to the Caveat Brush font.
|
||||
*
|
||||
* @param {string} family The name of the font family.
|
||||
* @return {string} The font that corresponds to the given family.
|
||||
* @static
|
||||
*/
|
||||
static determineFontFromFamily(family) {
|
||||
switch (family) {
|
||||
case 'sans': return 'Source Sans Pro';
|
||||
case 'serif': return 'Crimson Pro';
|
||||
case 'mono': return 'Source Code Pro';
|
||||
case 'draw':
|
||||
default: return 'Caveat Brush';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the width of a given text string using font metrics.
|
||||
* @param {string} text - The text to measure.
|
||||
* @param {opentype.Font} font - The loaded font object.
|
||||
* @param {number} fontSize - The size of the font.
|
||||
* @return {number} The width of the text.
|
||||
*/
|
||||
measureTextWidth(text, font, fontSize) {
|
||||
const scale = 1 / font.unitsPerEm * fontSize;
|
||||
const glyphs = font.stringToGlyphs(text);
|
||||
let width = 0;
|
||||
|
||||
glyphs.forEach((glyph) => {
|
||||
if (glyph.advanceWidth) {
|
||||
width += glyph.advanceWidth * scale;
|
||||
}
|
||||
});
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps text to fit within a specified width and height.
|
||||
* @param {string} text - The text to wrap.
|
||||
* @param {number} width - The width of the bounding box.
|
||||
* @return {string[]} An array of strings, each being a line.
|
||||
*/
|
||||
wrapText(text, width) {
|
||||
const config = JSON.parse(
|
||||
fs.readFileSync(
|
||||
'./config/settings.json',
|
||||
'utf8'));
|
||||
|
||||
const font = this.props?.font || 'draw';
|
||||
const fontPath = config.fonts[font];
|
||||
|
||||
const words = text.split(' ');
|
||||
let line = '';
|
||||
const lines = [];
|
||||
|
||||
// Read the font file into a Buffer
|
||||
const fontBuffer = fs.readFileSync(fontPath);
|
||||
|
||||
// Convert the Buffer to an ArrayBuffer
|
||||
const arrayBuffer = fontBuffer.buffer.slice(
|
||||
fontBuffer.byteOffset,
|
||||
fontBuffer.byteOffset + fontBuffer.byteLength);
|
||||
|
||||
// Parse the font using the ArrayBuffer
|
||||
const parsedFont = opentype.parse(arrayBuffer);
|
||||
const fontSize = Shape.determineFontSize(this.size);
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = line + word + ' ';
|
||||
const testWidth = this.measureTextWidth(
|
||||
testLine,
|
||||
parsedFont,
|
||||
fontSize);
|
||||
|
||||
if (testWidth > width) {
|
||||
if (line !== '') {
|
||||
lines.push(line);
|
||||
}
|
||||
line = word + ' ';
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (line !== '') {
|
||||
lines.push(line.trim());
|
||||
}
|
||||
|
||||
// Split newlines into separate lines
|
||||
const brokenLines = lines
|
||||
.map((line) => line.split('\n'))
|
||||
.flat();
|
||||
|
||||
return brokenLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws label text on the SVG canvas.
|
||||
* @param {SVGG} group The SVG group element to add the label to.
|
||||
*/
|
||||
drawLabel(group) {
|
||||
// Do nothing if there is no text
|
||||
if (!this.text) return;
|
||||
|
||||
// Sticky notes have a width and height of 200 and can't be resized,
|
||||
// unless the text becomes too long.
|
||||
if (!this.w) {
|
||||
this.w = 200;
|
||||
}
|
||||
|
||||
if (!this.h) {
|
||||
this.h = 200;
|
||||
}
|
||||
|
||||
if (!this.growY) {
|
||||
this.growY = 0;
|
||||
}
|
||||
|
||||
const width = this.w;
|
||||
const height = this.h + this.growY;
|
||||
|
||||
const x = Shape.alignHorizontally(this.align, width);
|
||||
let y = Shape.alignVertically(this.verticalAlign, height);
|
||||
const lineHeight = Shape.determineFontSize(this.size);
|
||||
const fontFamily = Shape.determineFontFromFamily(this.props?.font);
|
||||
|
||||
if (this.verticalAlign === 'end' || this.verticalAlign === 'middle') {
|
||||
y -= (lineHeight / 2);
|
||||
}
|
||||
|
||||
// Create a new SVG text element
|
||||
// Text is escaped by SVG.js
|
||||
const textElement = new Text()
|
||||
.move(x, y)
|
||||
.font({
|
||||
'family': fontFamily,
|
||||
'size': lineHeight,
|
||||
'anchor': this.align,
|
||||
'alignment-baseline': 'baseline',
|
||||
});
|
||||
|
||||
const lines = this.wrapText(this.text, width);
|
||||
|
||||
lines.forEach((line) => {
|
||||
const tspan = new Tspan()
|
||||
.text(line)
|
||||
.attr({
|
||||
x: x,
|
||||
dy: lineHeight,
|
||||
});
|
||||
|
||||
textElement.add(tspan);
|
||||
});
|
||||
|
||||
// Set the fill color for the text
|
||||
textElement.fill(this.labelColor || 'black');
|
||||
|
||||
// If there's a URL, make the text clickable
|
||||
if (this.url) {
|
||||
textElement.linkTo(this.url);
|
||||
}
|
||||
|
||||
group.add(textElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder method for drawing the shape.
|
||||
* Intended to be overridden by subclasses.
|
||||
*
|
||||
* @method draw
|
||||
* @return {G} An empty SVG group element.
|
||||
*/
|
||||
draw() {
|
||||
return new G();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An object representing various types of colors used in shapes.
|
||||
* This object is frozen to prevent modifications.
|
||||
*
|
||||
* @const {ColorTypes}
|
||||
*/
|
||||
export const ColorTypes = Object.freeze({
|
||||
ShapeColor: 'shape',
|
||||
FillColor: 'fill',
|
||||
SemiFillColor: 'semi',
|
||||
StickyColor: 'sticky',
|
||||
});
|
90
bbb-export-annotations/shapes/Star.js
Normal file
90
bbb-export-annotations/shapes/Star.js
Normal file
@ -0,0 +1,90 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
import {TAU} from './helpers.js';
|
||||
/**
|
||||
* Creates an SVG star shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Star
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Star extends Geo {
|
||||
/**
|
||||
* Calculates the vertices of an n-point star.
|
||||
*
|
||||
* @param {number} w - The width of the bounding box.
|
||||
* @param {number} h - The height of the bounding box.
|
||||
* @param {number} n - The number of points on the star.
|
||||
* @return {Array} - An array of {x, y} objects representing star vertices.
|
||||
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/primitives/utils.ts} Adapted from Tldraw.
|
||||
*/
|
||||
getStarVertices(w, h, n) {
|
||||
const sides = n;
|
||||
const step = TAU / sides / 2;
|
||||
|
||||
const rightMostIndex = Math.floor(sides / 4) * 2;
|
||||
const leftMostIndex = sides * 2 - rightMostIndex;
|
||||
const topMostIndex = 0;
|
||||
const bottomMostIndex = Math.floor(sides / 2) * 2;
|
||||
const maxX = (Math.cos(-(TAU/4) + rightMostIndex * step) * w) / 2;
|
||||
const minX = (Math.cos(-(TAU/4) + leftMostIndex * step) * w) / 2;
|
||||
const minY = (Math.sin(-(TAU/4) + topMostIndex * step) * h) / 2;
|
||||
const maxY = (Math.sin(-(TAU/4) + bottomMostIndex * step) * h) / 2;
|
||||
|
||||
const diffX = w - Math.abs(maxX - minX);
|
||||
const diffY = h - Math.abs(maxY - minY);
|
||||
|
||||
const offsetX = w / 2 + minX - (w / 2 - maxX);
|
||||
const offsetY = h / 2 + minY - (h / 2 - maxY);
|
||||
|
||||
const ratio = 1;
|
||||
const cx = (w - offsetX) / 2;
|
||||
const cy = (h - offsetY) / 2;
|
||||
|
||||
const ox = (w + diffX) / 2;
|
||||
const oy = (h + diffY) / 2;
|
||||
const ix = (ox * ratio) / 2;
|
||||
const iy = (oy * ratio) / 2;
|
||||
|
||||
const points = Array.from(Array(sides * 2)).map((_, i) => {
|
||||
const theta = -(TAU/4) + i * step;
|
||||
return {
|
||||
x: cx + (i % 2 ? ix : ox) * Math.cos(theta),
|
||||
y: cy + (i % 2 ? iy : oy) * Math.sin(theta),
|
||||
};
|
||||
});
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a star shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the star.
|
||||
*/
|
||||
draw() {
|
||||
const width = this.w;
|
||||
const height = this.h + this.growY;
|
||||
|
||||
// Get the vertices of the star
|
||||
const pointsOnPerimeter = this.getStarVertices(width, height, 5);
|
||||
|
||||
// Convert the vertices to SVG polygon points format
|
||||
const points = pointsOnPerimeter.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
|
||||
// Create the SVG polygon
|
||||
const starGroup = this.shapeGroup;
|
||||
const star = new SVGPolygon({
|
||||
points,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
// Fill the polygon if required
|
||||
this.setFill(star);
|
||||
starGroup.add(star);
|
||||
|
||||
this.drawLabel(starGroup);
|
||||
|
||||
return starGroup;
|
||||
}
|
||||
}
|
53
bbb-export-annotations/shapes/StickyNote.js
Normal file
53
bbb-export-annotations/shapes/StickyNote.js
Normal file
@ -0,0 +1,53 @@
|
||||
import {Rect} from '@svgdotjs/svg.js';
|
||||
import {Shape, ColorTypes} from './Shape.js';
|
||||
|
||||
/**
|
||||
* Represents a sticky note, extending the Shape class.
|
||||
* @extends {Shape}
|
||||
*/
|
||||
export class StickyNote extends Shape {
|
||||
/**
|
||||
* Creates an instance of a StickyNote.
|
||||
* @param {Object} params - Parameters for the sticky note.
|
||||
* @param {string} [params.url] - URL associated with the sticky note.
|
||||
* @param {string} [params.text=""] - Text content of the sticky note.
|
||||
* @param {string} [params.align] - Text alignment within the sticky note.
|
||||
* @param {string} [params.verticalAlign] - Vertical text alignment.
|
||||
* @param {number} [params.growY] - Additional height for long notes.
|
||||
* @param {ColorTypes} [params.color] - Color category for the sticky note.
|
||||
*/
|
||||
constructor(params) {
|
||||
super(params);
|
||||
this.url = this.props?.url;
|
||||
this.text = this.props?.text || '';
|
||||
this.align = this.props?.align;
|
||||
this.verticalAlign = this.props?.verticalAlign;
|
||||
this.growY = this.props?.growY;
|
||||
this.shapeColor = Shape.colorToHex(this.color, ColorTypes.StickyColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sticky note and adds it to the SVG.
|
||||
* Overrides the placeholder draw method in the Shape base class.
|
||||
* @override
|
||||
* @method draw
|
||||
* @return {G} An SVG group element containing the note.
|
||||
*/
|
||||
draw() {
|
||||
const stickyNote = this.shapeGroup;
|
||||
const rectW = 200;
|
||||
const rectH = 200 + this.growY;
|
||||
const cornerRadius = 10;
|
||||
|
||||
// Create rectangle element
|
||||
const rect = new Rect()
|
||||
.size(rectW, rectH)
|
||||
.radius(cornerRadius)
|
||||
.fill(this.shapeColor);
|
||||
|
||||
stickyNote.add(rect);
|
||||
this.drawLabel(stickyNote);
|
||||
|
||||
return stickyNote;
|
||||
}
|
||||
}
|
59
bbb-export-annotations/shapes/TextShape.js
Normal file
59
bbb-export-annotations/shapes/TextShape.js
Normal file
@ -0,0 +1,59 @@
|
||||
import {Text} from '@svgdotjs/svg.js';
|
||||
import {Shape} from './Shape.js';
|
||||
|
||||
/**
|
||||
* Draws the text shape on the SVG canvas, aligning and styling it
|
||||
* based on the provided properties.
|
||||
* @override
|
||||
* @return {G} The SVG group element containing the text element.
|
||||
*/
|
||||
export class TextShape extends Shape {
|
||||
/**
|
||||
* Constructs a new TextShape instance with the given parameters.
|
||||
* Inherits from Shape and initializes text-specific properties.
|
||||
*
|
||||
* @param {Object} params - The configuration object for the text shape.
|
||||
* @param {string} [params.text=""] - The text content for the shape.
|
||||
* @param {string} [params.align] - The horizontal text alignment.
|
||||
* @param {number} [params.w] - The width of the shape.
|
||||
* @param {number} [params.h] - The height of the shape.
|
||||
* @param {string} [params.font] - The font family for the text.
|
||||
*/
|
||||
constructor(params) {
|
||||
super(params);
|
||||
this.text = this.props?.text || '';
|
||||
this.align = this.props?.align;
|
||||
this.w = this.props?.w;
|
||||
this.h = this.props?.h;
|
||||
this.fontSize = Shape.determineFontSize(this.size);
|
||||
this.fontFamily = Shape.determineFontFromFamily(this.props?.font);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the text shape and adds it to the SVG.
|
||||
* Overrides the placeholder draw method in the Shape base class.
|
||||
* @override
|
||||
* @method draw
|
||||
* @return {G} An SVG group element containing the text.
|
||||
*/
|
||||
draw() {
|
||||
const x = Shape.alignHorizontally(this.align, this.w);
|
||||
const y = 0;
|
||||
|
||||
const textGroup = this.shapeGroup;
|
||||
const textElement = new Text()
|
||||
.text(this.text)
|
||||
.move(x, y)
|
||||
.font({
|
||||
'family': this.fontFamily,
|
||||
'size': this.fontSize,
|
||||
'anchor': this.align,
|
||||
'alignment-baseline': 'middle',
|
||||
})
|
||||
.fill(this.shapeColor);
|
||||
|
||||
textGroup.add(textElement);
|
||||
|
||||
return textGroup;
|
||||
}
|
||||
}
|
45
bbb-export-annotations/shapes/Trapezoid.js
Normal file
45
bbb-export-annotations/shapes/Trapezoid.js
Normal file
@ -0,0 +1,45 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG trapezoid shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Trapezoid
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Trapezoid extends Geo {
|
||||
/**
|
||||
* Draws a trapezoid shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the trapezoid.
|
||||
*/
|
||||
draw() {
|
||||
const width = this.w;
|
||||
const height = this.h + this.growY;
|
||||
|
||||
// Adjust this value as needed for the trapezoid
|
||||
const topWidth = width * 0.6;
|
||||
const xOffset = (width - topWidth) / 2;
|
||||
|
||||
// Shape begins from the upper left corner
|
||||
const points = [
|
||||
[xOffset, 0],
|
||||
[xOffset + topWidth, 0],
|
||||
[width, height],
|
||||
[0, height],
|
||||
].map((p) => p.join(',')).join(' ');
|
||||
|
||||
const trapezoidGroup = this.shapeGroup;
|
||||
const trapezoid = new SVGPolygon({
|
||||
points,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
this.setFill(trapezoid);
|
||||
trapezoidGroup.add(trapezoid);
|
||||
this.drawLabel(trapezoidGroup);
|
||||
|
||||
return trapezoidGroup;
|
||||
}
|
||||
}
|
41
bbb-export-annotations/shapes/Triangle.js
Normal file
41
bbb-export-annotations/shapes/Triangle.js
Normal file
@ -0,0 +1,41 @@
|
||||
import {Polygon as SVGPolygon} from '@svgdotjs/svg.js';
|
||||
import {Geo} from './Geo.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG triangle shape from Tldraw v2 JSON data.
|
||||
*
|
||||
* @class Triangle
|
||||
* @extends {Geo}
|
||||
*/
|
||||
export class Triangle extends Geo {
|
||||
/**
|
||||
* Draws a triangle shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element containing the triangle.
|
||||
*/
|
||||
draw() {
|
||||
const width = this.w;
|
||||
const height = this.h + this.growY;
|
||||
const halfWidth = width / 2;
|
||||
|
||||
// Shape begins from the upper left corner
|
||||
const points = [
|
||||
[halfWidth, 0],
|
||||
[width, height],
|
||||
[0, height],
|
||||
].map((p) => p.join(',')).join(' ');
|
||||
|
||||
const triangleGroup = this.shapeGroup;
|
||||
const triangle = new SVGPolygon({
|
||||
points,
|
||||
'stroke': this.shapeColor,
|
||||
'stroke-width': this.thickness,
|
||||
'style': this.dasharray,
|
||||
});
|
||||
|
||||
this.setFill(triangle);
|
||||
triangleGroup.add(triangle);
|
||||
this.drawLabel(triangleGroup);
|
||||
|
||||
return triangleGroup;
|
||||
}
|
||||
}
|
40
bbb-export-annotations/shapes/XBox.js
Normal file
40
bbb-export-annotations/shapes/XBox.js
Normal file
@ -0,0 +1,40 @@
|
||||
import {Line} from '@svgdotjs/svg.js';
|
||||
import {Rectangle} from './Rectangle.js';
|
||||
|
||||
/**
|
||||
* Creates an SVG "XBox" shape, which is a rectangle
|
||||
* with an "X" drawn through it.
|
||||
*
|
||||
* @class XBox
|
||||
* @extends {Rectangle}
|
||||
*/
|
||||
export class XBox extends Rectangle {
|
||||
/**
|
||||
* Draws an "XBox" shape on the SVG canvas.
|
||||
* @return {G} Returns the SVG group element
|
||||
* containing the rectangle and the X.
|
||||
*/
|
||||
draw() {
|
||||
// Draw the base rectangle
|
||||
const rectGroup = super.draw();
|
||||
|
||||
// Add the first diagonal line from upper-left to lower-right
|
||||
const line1 = new Line();
|
||||
line1.plot(0, 0, this.w, this.h + this.growY)
|
||||
.stroke({color: this.shapeColor, width: this.thickness})
|
||||
.style({dasharray: this.dasharray});
|
||||
|
||||
// Add the second diagonal line from upper-right to lower-left
|
||||
const line2 = new Line();
|
||||
line2.plot(this.w, 0, 0, this.h + this.growY)
|
||||
.stroke({color: this.shapeColor, width: this.thickness})
|
||||
.style({dasharray: this.dasharray});
|
||||
|
||||
// Add the lines to the group
|
||||
rectGroup.add(line1);
|
||||
rectGroup.add(line2);
|
||||
this.drawLabel(rectGroup);
|
||||
|
||||
return rectGroup;
|
||||
}
|
||||
}
|
64
bbb-export-annotations/shapes/geoFactory.js
Normal file
64
bbb-export-annotations/shapes/geoFactory.js
Normal file
@ -0,0 +1,64 @@
|
||||
import {Geo} from './Geo.js';
|
||||
import {Rectangle} from './Rectangle.js';
|
||||
import {Ellipse} from './Ellipse.js';
|
||||
import {Diamond} from './Diamond.js';
|
||||
import {Triangle} from './Triangle.js';
|
||||
import {Trapezoid} from './Trapezoid.js';
|
||||
import {Rhombus} from './Rhombus.js';
|
||||
import {Hexagon} from './Hexagon.js';
|
||||
import {Oval} from './Oval.js';
|
||||
import {Star} from './Star.js';
|
||||
import {ArrowRight} from './ArrowRight.js';
|
||||
import {ArrowLeft} from './ArrowLeft.js';
|
||||
import {ArrowUp} from './ArrowUp.js';
|
||||
import {ArrowDown} from './ArrowDown.js';
|
||||
import {XBox} from './XBox.js';
|
||||
import {Checkbox} from './Checkbox.js';
|
||||
import {Cloud} from './Cloud.js';
|
||||
/**
|
||||
* Creates a geometric object instance based on the provided annotations.
|
||||
*
|
||||
* @function createGeoObject
|
||||
* @param {Object} annotations - The annotations for the geometric object.
|
||||
* @param {Object} [annotations.props] - The properties of the annotations.
|
||||
* @param {String} [annotations.props.geo] - Which geometric object to create.
|
||||
* @return {Geo} The created geometric object.
|
||||
*/
|
||||
export function createGeoObject(annotations) {
|
||||
switch (annotations.props?.geo) {
|
||||
case 'rectangle':
|
||||
return new Rectangle(annotations);
|
||||
case 'ellipse':
|
||||
return new Ellipse(annotations);
|
||||
case 'diamond':
|
||||
return new Diamond(annotations);
|
||||
case 'triangle':
|
||||
return new Triangle(annotations);
|
||||
case 'trapezoid':
|
||||
return new Trapezoid(annotations);
|
||||
case 'rhombus':
|
||||
return new Rhombus(annotations);
|
||||
case 'hexagon':
|
||||
return new Hexagon(annotations);
|
||||
case 'oval':
|
||||
return new Oval(annotations);
|
||||
case 'star':
|
||||
return new Star(annotations);
|
||||
case 'arrow-right':
|
||||
return new ArrowRight(annotations);
|
||||
case 'arrow-left':
|
||||
return new ArrowLeft(annotations);
|
||||
case 'arrow-up':
|
||||
return new ArrowUp(annotations);
|
||||
case 'arrow-down':
|
||||
return new ArrowDown(annotations);
|
||||
case 'x-box':
|
||||
return new XBox(annotations);
|
||||
case 'check-box':
|
||||
return new Checkbox(annotations);
|
||||
case 'cloud':
|
||||
return new Cloud(annotations);
|
||||
default:
|
||||
return new Geo(annotations);
|
||||
}
|
||||
}
|
245
bbb-export-annotations/shapes/helpers.js
Normal file
245
bbb-export-annotations/shapes/helpers.js
Normal file
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Represents the constant TAU, which is equal to 2 * PI.
|
||||
*
|
||||
* TAU is often used in trigonometric calculations as it represents
|
||||
* one full turn in radians, making it more intuitive than using 2 * PI.
|
||||
* For example, half a circle is TAU / 2, a quarter is TAU / 4, etc.,
|
||||
* which makes the math easier to follow.
|
||||
*
|
||||
* @constant {number}
|
||||
*/
|
||||
export const TAU = Math.PI * 2;
|
||||
|
||||
/**
|
||||
* Sorts an array of objects lexicographically based on a nested key-value pair.
|
||||
*
|
||||
* @param {Array} array - The array to be sorted.
|
||||
* @param {string} key - The key in each object to be used for sorting.
|
||||
* @param {string} value - The nested key within the 'key' object to be used
|
||||
* for sorting.
|
||||
* @return {Array} - Returns a new array sorted lexicographically
|
||||
* by the specified nested key-value pair.
|
||||
*
|
||||
* @example
|
||||
* const data = [
|
||||
* {annotationInfo: {index: 'a1V'}, type: 'shape'},
|
||||
* {annotationInfo: {index: 'a2'}, type: 'shape'},
|
||||
* {annotationInfo: {index: 'a1'}, type: 'draw'}
|
||||
* ];
|
||||
* const sortedData = sortByKey(data, 'annotationInfo', 'index');
|
||||
* // Output: [{ annotationInfo: { index: 'a1' }, type: 'draw' },
|
||||
* { annotationInfo: { index: 'a1V' }, type: 'shape' },
|
||||
* { annotationInfo: { index: 'a2' }, type: 'shape' }]
|
||||
*/
|
||||
export function sortByKey(array, key, value) {
|
||||
return array.sort((a, b) => {
|
||||
const [x, y] = [a[key][value], b[key][value]];
|
||||
return x.localeCompare(y);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an angle from radians to degrees.
|
||||
*
|
||||
* @param {number} angle - The angle in radians.
|
||||
* @return {number} The angle in degrees, fixed to two decimal places.
|
||||
*/
|
||||
export function radToDegree(angle) {
|
||||
return parseFloat(angle * (360 / TAU)).toFixed(2) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random number generator based on a seed value.
|
||||
* This uses a variation of the xorshift algorithm to generate
|
||||
* pseudo-random numbers. The function returns a `next` function that,
|
||||
* when called, generates the next random number in sequence.
|
||||
*
|
||||
* @param {string} [seed=''] - The seed value for the random number generator.
|
||||
* Default is an empty string.
|
||||
* @return {Function} The `next` function to generate random numbers.
|
||||
* @see {@link https://github.com/tldraw/tldraw/blob/main/packages/utils/src/lib/number.ts} Adapted from Tldraw.
|
||||
*/
|
||||
export function rng(seed = '') {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let z = 0;
|
||||
let w = 0;
|
||||
|
||||
/**
|
||||
* Generates the next number in the pseudo-random sequence using bitwise
|
||||
* operations. This function uses a form of 'xorshift', a type of pseudo-
|
||||
* random number generator algorithm. It manipulates four state variables
|
||||
* \( x, y, z, w \) with bitwise operations to produce a new random number
|
||||
* upon each call. The returned value is scaled to the range [0, 2).
|
||||
* @return {number} The next pseudo-random number within [0, 2).
|
||||
*/
|
||||
function next() {
|
||||
const t = x ^ (x << 11);
|
||||
x = y;
|
||||
y = z;
|
||||
z = w;
|
||||
w ^= ((w >>> 19) ^ t ^ (t >>> 8)) >>> 0;
|
||||
return (w / 0x100000000) * 2;
|
||||
}
|
||||
|
||||
for (let k = 0; k < seed.length + 64; k++) {
|
||||
x ^= seed.charCodeAt(k) | 0;
|
||||
next();
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a point on the perimeter of a circle.
|
||||
*
|
||||
* @param {number} cx - The center x of the circle.
|
||||
* @param {number} cy - The center y of the circle.
|
||||
* @param {number} r - The radius of the circle.
|
||||
* @param {number} a - The angle in radians to get the point from.
|
||||
* @return {Object} A point object with 'x' and 'y' properties
|
||||
* @public
|
||||
*/
|
||||
export function getPointOnCircle(cx, cy, r, a) {
|
||||
return {
|
||||
x: cx + r * Math.cos(a),
|
||||
y: cy + r * Math.sin(a),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the angle (in radians) between a center point and another point
|
||||
* using the arctangent of the quotient of their coordinates.
|
||||
* The angle is measured in the coordinate system where x-axis points to the
|
||||
* right and y-axis points down. The angle is measured counterclockwise
|
||||
* from the positive x-axis.
|
||||
*
|
||||
* @param {Object} center - The center point with x and y coordinates.
|
||||
* @param {number} center.x - The x-coordinate of the center point.
|
||||
* @param {number} center.y - The y-coordinate of the center point.
|
||||
*
|
||||
* @param {Object} point - The other point with x and y coordinates.
|
||||
* @param {number} point.x - The x-coordinate of the other point.
|
||||
* @param {number} point.y - The y-coordinate of the other point.
|
||||
*
|
||||
* @return {number} The angle in radians between the line from the center
|
||||
* to the other point and the positive x-axis.
|
||||
*/
|
||||
export function angle(center, point) {
|
||||
const dy = point.y - center.y;
|
||||
const dx = point.x - center.x;
|
||||
return Math.atan2(dy, dx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the clockwise angular distance between two angles.
|
||||
*
|
||||
* This function takes two angles in radians and calculates the
|
||||
* shortest angular distance between them in the clockwise direction.
|
||||
* The result is also in radians and accounts for full circle rotation.
|
||||
*
|
||||
* @param {number} startAngle - The starting angle in radians.
|
||||
* @param {number} endAngle - The ending angle in radians.
|
||||
* @return {number} The Clockwise angular distance in radians
|
||||
* between the start and end angles.
|
||||
*/
|
||||
export function clockwiseAngleDist(startAngle, endAngle) {
|
||||
let l = endAngle - startAngle;
|
||||
if (l < 0) {
|
||||
l += TAU;
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the distance between two points.
|
||||
*
|
||||
* @param {Object} point1 - The first point, represented as an object {x, y}.
|
||||
* @param {Object} point2 - The second point, represented as an object {x, y}.
|
||||
* @return {number} - The calculated distance.
|
||||
*/
|
||||
export function calculateDistance(point1, point2) {
|
||||
const dx = point2.x - point1.x;
|
||||
const dy = point2.y - point1.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the circle that passes through three points A, B, and C.
|
||||
* Returns the circle's center as [x, y] and its radius.
|
||||
*
|
||||
* @param {number[]} A - Point A as [x1, y1].
|
||||
* @param {number[]} B - Point B as [x2, y2].
|
||||
* @param {number[]} C - Point C as [x3, y3].
|
||||
* @return {number[]|null} - The circle's center [x, y] and radius,
|
||||
* or null if the points are collinear.
|
||||
*/
|
||||
export function circleFromThreePoints(A, B, C) {
|
||||
const [x1, y1] = A;
|
||||
const [x2, y2] = B;
|
||||
const [x3, y3] = C;
|
||||
|
||||
const a = x1 * (y2 - y3) - y1 * (x2 - x3) + x2 * y3 - x3 * y2;
|
||||
|
||||
if (a === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const b =
|
||||
(x1 * x1 + y1 * y1) * (y3 - y2) +
|
||||
(x2 * x2 + y2 * y2) * (y1 - y3) +
|
||||
(x3 * x3 + y3 * y3) * (y2 - y1);
|
||||
|
||||
const c =
|
||||
(x1 * x1 + y1 * y1) * (x2 - x3) +
|
||||
(x2 * x2 + y2 * y2) * (x3 - x1) +
|
||||
(x3 * x3 + y3 * y3) * (x1 - x2);
|
||||
|
||||
const x = -b / (2 * a);
|
||||
const y = -c / (2 * a);
|
||||
|
||||
return [x, y, Math.hypot(x - x1, y - y1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a 2D vector represented as an array [x, y].
|
||||
*
|
||||
* @param {Array<number>} A - The 2D vector to normalize.
|
||||
* @return {Array<number>} The normalized vector,
|
||||
*/
|
||||
export function normalize(A) {
|
||||
const length = Math.sqrt(A[0] * A[0] + A[1] * A[1]);
|
||||
return [A[0] / length, A[1] / length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a vector [x,y] 90 degrees counter-clockwise.
|
||||
*
|
||||
* @param {Array<number>} vec - The 2D vector to rotate.
|
||||
* @return {Array<number>} The rotated vector.
|
||||
*/
|
||||
export function rotate(vec) {
|
||||
const [x, y] = vec;
|
||||
return [y, -x];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Escapes special characters in a string to their corresponding HTML entities
|
||||
* to prevent misinterpretation of HTML content. This function converts
|
||||
* ampersands, single quotes, double quotes, greater-than signs, and
|
||||
* less-than signs to their corresponding HTML entity codes, making it safe
|
||||
* to insert the string into HTML or XML content where these characters would
|
||||
* otherwise be mistaken for markup.
|
||||
*
|
||||
* @param {string} string - The string to be escaped.
|
||||
* @return {string} The escaped string with HTML entities.
|
||||
*/
|
||||
export function escapeSVGText(string) {
|
||||
return string
|
||||
.replace(/&/g, '\\&') // Escape ampersands.
|
||||
.replace(/'/g, '\\'') // Escape single quotes.
|
||||
.replace(/"/g, '\\"') // Escape double quotes.
|
||||
.replace(/>/g, '\\>') // Escape greater-than signs.
|
||||
.replace(/</g, '\\<'); // Escape less-than signs.
|
||||
}
|
@ -1,21 +1,20 @@
|
||||
const Logger = require('../lib/utils/logger');
|
||||
const axios = require('axios').default;
|
||||
const config = require('../config');
|
||||
const cp = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const redis = require('redis');
|
||||
const sanitize = require('sanitize-filename');
|
||||
const stream = require('stream');
|
||||
const WorkerStarter = require('../lib/utils/worker-starter');
|
||||
const {PresAnnStatusMsg} = require('../lib/utils/message-builder');
|
||||
const {workerData} = require('worker_threads');
|
||||
const {promisify} = require('util');
|
||||
import Logger from '../lib/utils/logger.js';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import redis from 'redis';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import stream from 'stream';
|
||||
import WorkerStarter from '../lib/utils/worker-starter.js';
|
||||
import {PresAnnStatusMsg} from '../lib/utils/message-builder.js';
|
||||
import {workerData} from 'worker_threads';
|
||||
import {promisify} from 'util';
|
||||
|
||||
const jobId = workerData.jobId;
|
||||
const logger = new Logger('presAnn Collector');
|
||||
logger.info(`Collecting job ${jobId}`);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
|
||||
const dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
|
||||
|
||||
// Takes the Job from the dropbox
|
||||
@ -23,11 +22,26 @@ const job = fs.readFileSync(path.join(dropbox, 'job'));
|
||||
const exportJob = JSON.parse(job);
|
||||
const jobType = exportJob.jobType;
|
||||
|
||||
/**
|
||||
* Asynchronously collects annotations from Redis, processes them,
|
||||
* and handles the collection of presentation page files. It removes
|
||||
* the annotations from Redis after collection, writes them to a file,
|
||||
* and manages the retrieval of SVGs, PNGs, or JPEGs. Errors during the
|
||||
* process are logged, and the status of the operation is published to
|
||||
* a Redis channel.
|
||||
*
|
||||
* @async
|
||||
* @function collectAnnotationsFromRedis
|
||||
* @throws Will log an error if an error occurs in connecting to Redis.
|
||||
* @return {Promise<void>} Resolves when the function has completed its task.
|
||||
*/
|
||||
async function collectAnnotationsFromRedis() {
|
||||
const client = redis.createClient({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
socket: {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (err) => logger.info('Redis Client Error', err));
|
||||
@ -59,49 +73,46 @@ async function collectAnnotationsFromRedis() {
|
||||
const statusUpdate = new PresAnnStatusMsg(exportJob);
|
||||
|
||||
if (fs.existsSync(pdfFile)) {
|
||||
// If there's a PDF file, we leverage the existing converted SVG slides
|
||||
for (const p of pages) {
|
||||
const pageNumber = p.page;
|
||||
const outputFile = path.join(dropbox, `slide${pageNumber}`);
|
||||
const imageName = `slide${pageNumber}`;
|
||||
const convertedSVG = path.join(
|
||||
exportJob.presLocation,
|
||||
'svgs',
|
||||
`${imageName}.svg`);
|
||||
|
||||
// CairoSVG doesn't handle transparent SVG and PNG embeds properly,
|
||||
// e.g., in rasterized text. So textboxes may get a black background
|
||||
// when downloading/exporting repeatedly. To avoid that, we take slides
|
||||
// from the uploaded file, but later probe the dimensions from the SVG
|
||||
// so it matches what was shown in the browser.
|
||||
|
||||
const extract_png_from_pdf = [
|
||||
'-png',
|
||||
'-f', pageNumber,
|
||||
'-l', pageNumber,
|
||||
'-scale-to', config.collector.pngWidthRasterizedSlides,
|
||||
'-singlefile',
|
||||
'-cropbox',
|
||||
pdfFile, outputFile,
|
||||
];
|
||||
const outputFile = path.join(dropbox, `slide${pageNumber}.svg`);
|
||||
|
||||
try {
|
||||
cp.spawnSync(config.shared.pdftocairo, extract_png_from_pdf, {shell: false});
|
||||
fs.copyFileSync(convertedSVG, outputFile);
|
||||
} catch (error) {
|
||||
logger.error(`PDFtoCairo failed extracting slide ${pageNumber} in job ${jobId}: ${error.message}`);
|
||||
logger.error('Failed collecting slide ' + pageNumber +
|
||||
' in job ' + jobId + ': ' + error.message);
|
||||
statusUpdate.setError();
|
||||
}
|
||||
|
||||
await client.publish(config.redis.channels.publish, statusUpdate.build(pageNumber));
|
||||
await client.publish(
|
||||
config.redis.channels.publish,
|
||||
statusUpdate.build(pageNumber));
|
||||
}
|
||||
} else {
|
||||
const imageName = 'slide1';
|
||||
|
||||
if (fs.existsSync(`${presFile}.png`)) {
|
||||
fs.copyFileSync(`${presFile}.png`, path.join(dropbox, `${imageName}.png`));
|
||||
fs.copyFileSync(`${presFile}.png`,
|
||||
path.join(dropbox, `${imageName}.png`));
|
||||
} else if (fs.existsSync(`${presFile}.jpeg`)) {
|
||||
fs.copyFileSync(`${presFile}.jpeg`, path.join(dropbox, `${imageName}.jpeg`));
|
||||
fs.copyFileSync(`${presFile}.jpeg`,
|
||||
path.join(dropbox, `${imageName}.jpeg`));
|
||||
} else if (fs.existsSync(`${presFile}.jpg`)) {
|
||||
// JPG file available: copy changing extension to JPEG
|
||||
fs.copyFileSync(`${presFile}.jpg`, path.join(dropbox, `${imageName}.jpeg`));
|
||||
fs.copyFileSync(`${presFile}.jpg`,
|
||||
path.join(dropbox, `${imageName}.jpeg`));
|
||||
} else {
|
||||
await client.publish(config.redis.channels.publish, statusUpdate.build());
|
||||
client.disconnect();
|
||||
return logger.error(`No PDF, PNG, JPG or JPEG file available for job ${jobId}`);
|
||||
return logger.error(`PDF/PNG/JPG/JPEG file not found for job ${jobId}`);
|
||||
}
|
||||
|
||||
await client.publish(config.redis.channels.publish, statusUpdate.build());
|
||||
@ -113,6 +124,15 @@ async function collectAnnotationsFromRedis() {
|
||||
process.process();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise that resolves after a specified number of milliseconds,
|
||||
* effectively pausing execution for that duration. Used to delay operations
|
||||
* in an asynchronous function.
|
||||
* @async
|
||||
* @function sleep
|
||||
* @param {number} ms - The amount of time in milliseconds to sleep.
|
||||
* @return {Promise<void>} Resolves after the specified number of milliseconds.
|
||||
*/
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
@ -129,8 +149,10 @@ async function collectSharedNotes(retries = 3) {
|
||||
const padId = exportJob.presId;
|
||||
const notesFormat = 'pdf';
|
||||
|
||||
const serverSideFilename = `${sanitize(exportJob.serverSideFilename.replace(/\s/g, '_'))}.${notesFormat}`;
|
||||
const notes_endpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`;
|
||||
const underscoredFilename = exportJob.serverSideFilename.replace(/\s/g, '_');
|
||||
const sanitizedFilename = sanitize(underscoredFilename);
|
||||
const serverSideFilename = `${sanitizedFilename}.${notesFormat}`;
|
||||
const notesEndpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`;
|
||||
const filePath = path.join(dropbox, serverSideFilename);
|
||||
|
||||
const finishedDownload = promisify(stream.finished);
|
||||
@ -139,7 +161,7 @@ async function collectSharedNotes(retries = 3) {
|
||||
try {
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: notes_endpoint,
|
||||
url: notesEndpoint,
|
||||
responseType: 'stream',
|
||||
});
|
||||
response.data.pipe(writer);
|
||||
@ -157,13 +179,21 @@ async function collectSharedNotes(retries = 3) {
|
||||
}
|
||||
}
|
||||
|
||||
const notifier = new WorkerStarter({jobType, jobId, serverSideFilename, filename: exportJob.filename});
|
||||
const notifier = new WorkerStarter({jobType, jobId,
|
||||
serverSideFilename, filename: exportJob.filename});
|
||||
notifier.notify();
|
||||
}
|
||||
|
||||
switch (jobType) {
|
||||
case 'PresentationWithAnnotationExportJob': return collectAnnotationsFromRedis();
|
||||
case 'PresentationWithAnnotationDownloadJob': return collectAnnotationsFromRedis();
|
||||
case 'PadCaptureJob': return collectSharedNotes();
|
||||
default: return logger.error(`Unknown job type ${jobType}`);
|
||||
case 'PresentationWithAnnotationExportJob':
|
||||
collectAnnotationsFromRedis();
|
||||
break;
|
||||
case 'PresentationWithAnnotationDownloadJob':
|
||||
collectAnnotationsFromRedis();
|
||||
break;
|
||||
case 'PadCaptureJob':
|
||||
collectSharedNotes();
|
||||
break;
|
||||
default:
|
||||
logger.error(`Unknown job type ${jobType}`);
|
||||
}
|
||||
|
@ -1,16 +1,17 @@
|
||||
const Logger = require('../lib/utils/logger');
|
||||
const config = require('../config');
|
||||
const fs = require('fs');
|
||||
const FormData = require('form-data');
|
||||
const redis = require('redis');
|
||||
const axios = require('axios').default;
|
||||
const path = require('path');
|
||||
const {NewPresFileAvailableMsg} = require('../lib/utils/message-builder');
|
||||
|
||||
const {workerData} = require('worker_threads');
|
||||
const [jobType, jobId, serverSideFilename] = [workerData.jobType, workerData.jobId, workerData.serverSideFilename];
|
||||
import Logger from '../lib/utils/logger.js';
|
||||
import fs from 'fs';
|
||||
import FormData from 'form-data';
|
||||
import redis from 'redis';
|
||||
import axios from 'axios';
|
||||
import path from 'path';
|
||||
import {NewPresFileAvailableMsg} from '../lib/utils/message-builder.js';
|
||||
import {workerData} from 'worker_threads';
|
||||
const [jobType, jobId, serverSideFilename] = [workerData.jobType,
|
||||
workerData.jobId,
|
||||
workerData.serverSideFilename];
|
||||
|
||||
const logger = new Logger('presAnn Notifier Worker');
|
||||
const config = JSON.parse(fs.readFileSync('./config/settings.json', 'utf8'));
|
||||
|
||||
const dropbox = `${config.shared.presAnnDropboxDir}/${jobId}`;
|
||||
const job = fs.readFileSync(path.join(dropbox, 'job'));
|
||||
@ -20,15 +21,18 @@ const exportJob = JSON.parse(job);
|
||||
* sending a message through Redis PubSub */
|
||||
async function notifyMeetingActor() {
|
||||
const client = redis.createClient({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
socket: {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port
|
||||
}
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
client.on('error', (err) => logger.info('Redis Client Error', err));
|
||||
|
||||
const link = path.join('presentation',
|
||||
const link = path.join(
|
||||
'presentation',
|
||||
exportJob.parentMeetingId, exportJob.parentMeetingId,
|
||||
exportJob.presId, 'pdf', jobId, serverSideFilename);
|
||||
|
||||
@ -37,14 +41,17 @@ async function notifyMeetingActor() {
|
||||
logger.info(`Annotated PDF available at ${link}`);
|
||||
await client.publish(config.redis.channels.publish, notification.build());
|
||||
client.disconnect();
|
||||
|
||||
}
|
||||
|
||||
/** Upload PDF to a BBB room
|
||||
* @param {String} filePath - Absolute path to the file, including the extension
|
||||
*/
|
||||
async function upload(filePath) {
|
||||
const callbackUrl = `${config.bbbWebAPI}/bigbluebutton/presentation/${exportJob.presentationUploadToken}/upload`;
|
||||
const apiPath = '/bigbluebutton/presentation/';
|
||||
const uploadToken = exportJob.presentationUploadToken;
|
||||
const uploadAction = '/upload';
|
||||
const callbackUrl = config.bbbWebAPI + apiPath + uploadToken + uploadAction;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('conference', exportJob.parentMeetingId);
|
||||
formData.append('pod_id', config.notifier.pod_id);
|
||||
@ -64,7 +71,10 @@ async function upload(filePath) {
|
||||
if (jobType == 'PresentationWithAnnotationDownloadJob') {
|
||||
notifyMeetingActor();
|
||||
} else if (jobType == 'PresentationWithAnnotationExportJob') {
|
||||
const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${serverSideFilename}`;
|
||||
const baseDirectory = exportJob.presLocation;
|
||||
const subDirectory = 'pdfs';
|
||||
const filePath = path.join(baseDirectory, subDirectory,
|
||||
jobId, serverSideFilename);
|
||||
upload(filePath);
|
||||
} else if (jobType == 'PadCaptureJob') {
|
||||
const filePath = `${dropbox}/${serverSideFilename}`;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@ export default function buildRedisMessage(sessionVariables: Record<string, unkno
|
||||
|
||||
const body = {
|
||||
userId: routing.userId,
|
||||
networkRttInMs: input.networkRttInMs
|
||||
};
|
||||
|
||||
return { eventName, routing, header, body };
|
||||
|
@ -1,23 +0,0 @@
|
||||
import { RedisMessage } from '../types';
|
||||
|
||||
export default function buildRedisMessage(sessionVariables: Record<string, unknown>, input: Record<string, unknown>): RedisMessage {
|
||||
const eventName = `UserConnectionUpdateRttReqMsg`;
|
||||
|
||||
const routing = {
|
||||
meetingId: sessionVariables['x-hasura-meetingid'] as String,
|
||||
userId: sessionVariables['x-hasura-userid'] as String
|
||||
};
|
||||
|
||||
const header = {
|
||||
name: eventName,
|
||||
meetingId: routing.meetingId,
|
||||
userId: routing.userId
|
||||
};
|
||||
|
||||
const body = {
|
||||
userId: routing.userId,
|
||||
networkRttInMs: input.networkRttInMs
|
||||
};
|
||||
|
||||
return { eventName, routing, header, body };
|
||||
}
|
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/iMDT/bbb-graphql-middleware/internal/common"
|
||||
@ -10,7 +11,9 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -25,6 +28,36 @@ func main() {
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
log := log.WithField("_routine", "main")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
hasuraConnections := common.GetActivitiesOverview()["__HasuraConnection"].Started
|
||||
topMessages := make(map[string]common.ActivitiesOverviewObj)
|
||||
for index, item := range common.GetActivitiesOverview() {
|
||||
if strings.HasPrefix(index, "_") || item.Started > hasuraConnections*3 || item.DataReceived > hasuraConnections*5 {
|
||||
topMessages[index] = item
|
||||
}
|
||||
}
|
||||
|
||||
jsonOverviewBytes, err := json.Marshal(topMessages)
|
||||
if err != nil {
|
||||
log.Errorf("Error occurred during marshaling. Error: %s", err.Error())
|
||||
}
|
||||
|
||||
log.WithField("data", string(jsonOverviewBytes)).Info("Top Activities Overview")
|
||||
|
||||
activitiesOverviewSummary := make(map[string]int64)
|
||||
activitiesOverviewSummary["activeWsConnections"] = common.GetActivitiesOverview()["__WebsocketConnection"].Started - common.GetActivitiesOverview()["__WebsocketConnection"].Completed
|
||||
activitiesOverviewSummary["activeBrowserHandlers"] = common.GetActivitiesOverview()["__BrowserConnection"].Started - common.GetActivitiesOverview()["__BrowserConnection"].Completed
|
||||
activitiesOverviewSummary["activeSubscriptions"] = common.GetActivitiesOverview()["_Sum-subscription"].Started - common.GetActivitiesOverview()["_Sum-subscription"].Completed
|
||||
activitiesOverviewSummary["pendingMutations"] = common.GetActivitiesOverview()["_Sum-mutation"].Started - common.GetActivitiesOverview()["_Sum-mutation"].Completed
|
||||
activitiesOverviewSummary["numGoroutine"] = int64(runtime.NumGoroutine())
|
||||
jsonOverviewSummaryBytes, _ := json.Marshal(activitiesOverviewSummary)
|
||||
log.WithField("data", string(jsonOverviewSummaryBytes)).Info("Activities Overview Summary")
|
||||
}
|
||||
}()
|
||||
|
||||
common.InitUniqueID()
|
||||
log = log.WithField("graphql-middleware-uid", common.GetUniqueID())
|
||||
|
||||
@ -36,6 +69,10 @@ func main() {
|
||||
// Listen msgs from akka (for example to invalidate connection)
|
||||
go websrv.StartRedisListener()
|
||||
|
||||
if jsonPatchDisabled := os.Getenv("BBB_GRAPHQL_MIDDLEWARE_JSON_PATCH_DISABLED"); jsonPatchDisabled != "" {
|
||||
log.Infof("Json Patch Disabled!")
|
||||
}
|
||||
|
||||
// Websocket listener
|
||||
|
||||
//Define IP to listen
|
||||
@ -65,6 +102,9 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
common.ActivitiesOverviewStarted("__WebsocketConnection")
|
||||
defer common.ActivitiesOverviewCompleted("__WebsocketConnection")
|
||||
|
||||
if err := rateLimiter.Wait(ctx); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
http.Error(w, "Request cancelled or rate limit exceeded", http.StatusTooManyRequests)
|
||||
|
@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var uniqueID string
|
||||
@ -13,3 +14,61 @@ func InitUniqueID() {
|
||||
func GetUniqueID() string {
|
||||
return uniqueID
|
||||
}
|
||||
|
||||
type ActivitiesOverviewObj struct {
|
||||
Started int64
|
||||
Completed int64
|
||||
DataReceived int64
|
||||
}
|
||||
|
||||
var activitiesOverview = make(map[string]ActivitiesOverviewObj)
|
||||
var activitiesOverviewMux = sync.Mutex{}
|
||||
|
||||
func ActivitiesOverviewStarted(index string) {
|
||||
activitiesOverviewMux.Lock()
|
||||
defer activitiesOverviewMux.Unlock()
|
||||
|
||||
if _, exists := activitiesOverview[index]; !exists {
|
||||
activitiesOverview[index] = ActivitiesOverviewObj{
|
||||
Started: 0,
|
||||
Completed: 0,
|
||||
DataReceived: 0,
|
||||
}
|
||||
}
|
||||
|
||||
updatedValues := activitiesOverview[index]
|
||||
updatedValues.Started++
|
||||
|
||||
activitiesOverview[index] = updatedValues
|
||||
}
|
||||
|
||||
func ActivitiesOverviewDataReceived(index string) {
|
||||
activitiesOverviewMux.Lock()
|
||||
defer activitiesOverviewMux.Unlock()
|
||||
|
||||
if updatedValues, exists := activitiesOverview[index]; exists {
|
||||
updatedValues.DataReceived++
|
||||
|
||||
activitiesOverview[index] = updatedValues
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func ActivitiesOverviewCompleted(index string) {
|
||||
activitiesOverviewMux.Lock()
|
||||
defer activitiesOverviewMux.Unlock()
|
||||
|
||||
if updatedValues, exists := activitiesOverview[index]; exists {
|
||||
updatedValues.Completed++
|
||||
|
||||
activitiesOverview[index] = updatedValues
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func GetActivitiesOverview() map[string]ActivitiesOverviewObj {
|
||||
activitiesOverviewMux.Lock()
|
||||
defer activitiesOverviewMux.Unlock()
|
||||
|
||||
return activitiesOverview
|
||||
}
|
||||
|
@ -24,6 +24,18 @@ var hasuraEndpoint = os.Getenv("BBB_GRAPHQL_MIDDLEWARE_HASURA_WS")
|
||||
// Hasura client connection
|
||||
func HasuraClient(browserConnection *common.BrowserConnection, cookies []*http.Cookie, fromBrowserToHasuraChannel *common.SafeChannel, fromHasuraToBrowserChannel *common.SafeChannel) error {
|
||||
log := log.WithField("_routine", "HasuraClient").WithField("browserConnectionId", browserConnection.Id)
|
||||
common.ActivitiesOverviewStarted("__HasuraConnection")
|
||||
defer common.ActivitiesOverviewCompleted("__HasuraConnection")
|
||||
|
||||
defer func() {
|
||||
//Remove subscriptions from ActivitiesOverview here once Hasura-Reader will ignore "complete" msg for them
|
||||
browserConnection.ActiveSubscriptionsMutex.RLock()
|
||||
for _, subscription := range browserConnection.ActiveSubscriptions {
|
||||
common.ActivitiesOverviewStarted(string(subscription.Type) + "-" + subscription.OperationName)
|
||||
common.ActivitiesOverviewStarted("_Sum-" + string(subscription.Type))
|
||||
}
|
||||
browserConnection.ActiveSubscriptionsMutex.RUnlock()
|
||||
}()
|
||||
|
||||
// Obtain id for this connection
|
||||
lastHasuraConnectionId++
|
||||
|
@ -61,6 +61,12 @@ func handleMessageReceivedFromHasura(hc *common.HasuraConnection, fromHasuraToBr
|
||||
//When Hasura send msg type "complete", this query is finished
|
||||
if messageType == "complete" {
|
||||
handleCompleteMessage(hc, queryId)
|
||||
common.ActivitiesOverviewCompleted(string(subscription.Type) + "-" + subscription.OperationName)
|
||||
common.ActivitiesOverviewCompleted("_Sum-" + string(subscription.Type))
|
||||
}
|
||||
|
||||
if messageType == "data" {
|
||||
common.ActivitiesOverviewDataReceived(string(subscription.Type) + "-" + subscription.OperationName)
|
||||
}
|
||||
|
||||
if messageType == "data" &&
|
||||
|
@ -1,10 +1,13 @@
|
||||
package writer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/iMDT/bbb-graphql-middleware/internal/common"
|
||||
"github.com/iMDT/bbb-graphql-middleware/internal/msgpatch"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"nhooyr.io/websocket/wsjson"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@ -105,6 +108,9 @@ RangeLoop:
|
||||
if ok && strings.HasPrefix(operationName, "Patched_") {
|
||||
jsonPatchSupported = true
|
||||
}
|
||||
if jsonPatchDisabled := os.Getenv("BBB_GRAPHQL_MIDDLEWARE_JSON_PATCH_DISABLED"); jsonPatchDisabled != "" {
|
||||
jsonPatchSupported = false
|
||||
}
|
||||
|
||||
browserConnection.ActiveSubscriptionsMutex.Lock()
|
||||
browserConnection.ActiveSubscriptions[queryId] = common.GraphQlSubscription{
|
||||
@ -121,12 +127,21 @@ RangeLoop:
|
||||
}
|
||||
// log.Tracef("Current queries: %v", browserConnection.ActiveSubscriptions)
|
||||
browserConnection.ActiveSubscriptionsMutex.Unlock()
|
||||
|
||||
common.ActivitiesOverviewStarted(string(messageType) + "-" + operationName)
|
||||
common.ActivitiesOverviewStarted("_Sum-" + string(messageType))
|
||||
|
||||
}
|
||||
|
||||
if fromBrowserMessageAsMap["type"] == "stop" {
|
||||
var queryId = fromBrowserMessageAsMap["id"].(string)
|
||||
browserConnection.ActiveSubscriptionsMutex.RLock()
|
||||
jsonPatchSupported := browserConnection.ActiveSubscriptions[queryId].JsonPatchSupported
|
||||
|
||||
//Remove subscriptions from ActivitiesOverview here once Hasura-Reader will ignore "complete" msg for them
|
||||
common.ActivitiesOverviewCompleted(string(browserConnection.ActiveSubscriptions[queryId].Type) + "-" + browserConnection.ActiveSubscriptions[queryId].OperationName)
|
||||
common.ActivitiesOverviewCompleted("_Sum-" + string(browserConnection.ActiveSubscriptions[queryId].Type))
|
||||
|
||||
browserConnection.ActiveSubscriptionsMutex.RUnlock()
|
||||
if jsonPatchSupported {
|
||||
msgpatch.RemoveConnSubscriptionCacheFile(browserConnection, queryId)
|
||||
@ -144,7 +159,9 @@ RangeLoop:
|
||||
log.Tracef("sending to hasura: %v", fromBrowserMessageAsMap)
|
||||
err := wsjson.Write(hc.Context, hc.Websocket, fromBrowserMessageAsMap)
|
||||
if err != nil {
|
||||
log.Errorf("error on write (we're disconnected from hasura): %v", err)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
log.Errorf("error on write (we're disconnected from hasura): %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,8 @@ var BrowserConnectionsMutex = &sync.RWMutex{}
|
||||
// This is the connection that comes from browser
|
||||
func ConnectionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := log.WithField("_routine", "ConnectionHandler")
|
||||
common.ActivitiesOverviewStarted("__BrowserConnection")
|
||||
defer common.ActivitiesOverviewCompleted("__BrowserConnection")
|
||||
|
||||
// Obtain id for this connection
|
||||
lastBrowserConnectionId++
|
||||
@ -48,6 +50,7 @@ func ConnectionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
browserWsConn, err := websocket.Accept(w, r, &acceptOptions)
|
||||
browserWsConn.SetReadLimit(9999999) //10MB
|
||||
if err != nil {
|
||||
log.Errorf("error: %v", err)
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ func BrowserConnectionReader(browserConnectionId string, ctx context.Context, ct
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debugf("Closing Browser ws connection as Context was cancelled!")
|
||||
} else {
|
||||
log.Debugf("Hasura is disconnected, skipping reading of ws message: %v", err)
|
||||
log.Debugf("Browser is disconnected, skipping reading of ws message: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -670,10 +670,9 @@ JOIN "user" u ON u."userId" = "user_breakoutRoom"."userId";
|
||||
CREATE TABLE "user_connectionStatus" (
|
||||
"userId" varchar(50) PRIMARY KEY REFERENCES "user"("userId") ON DELETE CASCADE,
|
||||
"meetingId" varchar(100) REFERENCES "meeting"("meetingId") ON DELETE CASCADE,
|
||||
"connectionAliveAtMaxIntervalMs" numeric,
|
||||
"connectionAliveAt" timestamp with time zone,
|
||||
"userClientResponseAt" timestamp with time zone,
|
||||
"networkRttInMs" numeric,
|
||||
"applicationRttInMs" numeric,
|
||||
"status" varchar(25),
|
||||
"statusUpdatedAt" timestamp with time zone
|
||||
);
|
||||
@ -681,9 +680,30 @@ create index "idx_user_connectionStatus_meetingId" on "user_connectionStatus"("m
|
||||
|
||||
create view "v_user_connectionStatus" as select * from "user_connectionStatus";
|
||||
|
||||
|
||||
--Populate connectionAliveAtMaxIntervalMs to calc clientNotResponding
|
||||
--It will sum settings public.stats.interval + public.stats.rtt (critical)
|
||||
CREATE OR REPLACE FUNCTION "update_connectionAliveAtMaxIntervalMs"()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
SELECT ("clientSettingsJson"->'public'->'stats'->'rtt'->>(jsonb_array_length("clientSettingsJson"->'public'->'stats'->'rtt') - 1))::int
|
||||
+
|
||||
("clientSettingsJson"->'public'->'stats'->'interval')::int
|
||||
INTO NEW."connectionAliveAtMaxIntervalMs"
|
||||
from "meeting_clientSettings" mcs
|
||||
where mcs."meetingId" = NEW."meetingId";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "trigger_update_connectionAliveAtMaxIntervalMs"
|
||||
BEFORE INSERT ON "user_connectionStatus"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "update_connectionAliveAtMaxIntervalMs"();
|
||||
|
||||
|
||||
--CREATE TABLE "user_connectionStatusHistory" (
|
||||
-- "userId" varchar(50) REFERENCES "user"("userId") ON DELETE CASCADE,
|
||||
-- "applicationRttInMs" numeric,
|
||||
-- "status" varchar(25),
|
||||
-- "statusUpdatedAt" timestamp with time zone
|
||||
--);
|
||||
@ -692,7 +712,6 @@ create view "v_user_connectionStatus" as select * from "user_connectionStatus";
|
||||
-- "status" varchar(25),
|
||||
-- "totalOfOccurrences" integer,
|
||||
-- "highestNetworkRttInMs" numeric,
|
||||
-- "highestApplicationRttInMs" numeric,
|
||||
-- "statusInsertedAt" timestamp with time zone,
|
||||
-- "statusUpdatedAt" timestamp with time zone,
|
||||
-- CONSTRAINT "user_connectionStatusHistory_pkey" PRIMARY KEY ("userId","status")
|
||||
@ -707,9 +726,6 @@ CREATE TABLE "user_connectionStatusMetrics" (
|
||||
"lowestNetworkRttInMs" numeric,
|
||||
"highestNetworkRttInMs" numeric,
|
||||
"lastNetworkRttInMs" numeric,
|
||||
"lowestApplicationRttInMs" numeric,
|
||||
"highestApplicationRttInMs" numeric,
|
||||
"lastApplicationRttInMs" numeric,
|
||||
CONSTRAINT "user_connectionStatusMetrics_pkey" PRIMARY KEY ("userId","status")
|
||||
);
|
||||
|
||||
@ -717,70 +733,38 @@ create index "idx_user_connectionStatusMetrics_userId" on "user_connectionStatus
|
||||
|
||||
--This function populate rtt, status and the table user_connectionStatusMetrics
|
||||
CREATE OR REPLACE FUNCTION "update_user_connectionStatus_trigger_func"() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
"newApplicationRttInMs" numeric;
|
||||
"newStatus" varchar(25);
|
||||
BEGIN
|
||||
IF NEW."connectionAliveAt" IS NULL OR NEW."userClientResponseAt" IS NULL THEN
|
||||
IF NEW."connectionAliveAt" IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
"newApplicationRttInMs" := (EXTRACT(EPOCH FROM (NEW."userClientResponseAt" - NEW."connectionAliveAt")) * 1000);
|
||||
"newStatus" := CASE WHEN COALESCE(NEW."networkRttInMs",0) > 2000 THEN 'critical'
|
||||
WHEN COALESCE(NEW."networkRttInMs",0) > 1000 THEN 'danger'
|
||||
WHEN COALESCE(NEW."networkRttInMs",0) > 500 THEN 'warning'
|
||||
ELSE 'normal' END;
|
||||
|
||||
--Update table user_connectionStatusMetrics
|
||||
WITH upsert AS (UPDATE "user_connectionStatusMetrics" SET
|
||||
"occurrencesCount" = "user_connectionStatusMetrics"."occurrencesCount" + 1,
|
||||
"highestApplicationRttInMs" = GREATEST("user_connectionStatusMetrics"."highestApplicationRttInMs","newApplicationRttInMs"),
|
||||
"lowestApplicationRttInMs" = LEAST("user_connectionStatusMetrics"."lowestApplicationRttInMs","newApplicationRttInMs"),
|
||||
"lastApplicationRttInMs" = "newApplicationRttInMs",
|
||||
"highestNetworkRttInMs" = GREATEST("user_connectionStatusMetrics"."highestNetworkRttInMs",NEW."networkRttInMs"),
|
||||
"lowestNetworkRttInMs" = LEAST("user_connectionStatusMetrics"."lowestNetworkRttInMs",NEW."networkRttInMs"),
|
||||
"lastNetworkRttInMs" = NEW."networkRttInMs",
|
||||
"lastOccurrenceAt" = current_timestamp
|
||||
WHERE "userId"=NEW."userId" AND "status"= "newStatus" RETURNING *)
|
||||
WHERE "userId"=NEW."userId" AND "status"= NEW."status" RETURNING *)
|
||||
INSERT INTO "user_connectionStatusMetrics"("userId","status","occurrencesCount", "firstOccurrenceAt",
|
||||
"highestApplicationRttInMs", "lowestApplicationRttInMs", "lastApplicationRttInMs",
|
||||
"highestNetworkRttInMs", "lowestNetworkRttInMs", "lastNetworkRttInMs")
|
||||
SELECT NEW."userId", "newStatus", 1, current_timestamp,
|
||||
"newApplicationRttInMs", "newApplicationRttInMs", "newApplicationRttInMs",
|
||||
SELECT NEW."userId", NEW."status", 1, current_timestamp,
|
||||
NEW."networkRttInMs", NEW."networkRttInMs", NEW."networkRttInMs"
|
||||
WHERE NOT EXISTS (SELECT * FROM upsert);
|
||||
--Update networkRttInMs, applicationRttInMs, status, statusUpdatedAt in user_connectionStatus
|
||||
UPDATE "user_connectionStatus"
|
||||
SET "applicationRttInMs" = "newApplicationRttInMs",
|
||||
"status" = "newStatus",
|
||||
"statusUpdatedAt" = now()
|
||||
WHERE "userId" = NEW."userId";
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "update_user_connectionStatus_trigger" AFTER UPDATE OF "userClientResponseAt" ON "user_connectionStatus"
|
||||
CREATE TRIGGER "update_user_connectionStatus_trigger" AFTER UPDATE OF "connectionAliveAt" ON "user_connectionStatus"
|
||||
FOR EACH ROW EXECUTE FUNCTION "update_user_connectionStatus_trigger_func"();
|
||||
|
||||
--This function clear userClientResponseAt and applicationRttInMs when connectionAliveAt is updated
|
||||
CREATE OR REPLACE FUNCTION "update_user_connectionStatus_connectionAliveAt_trigger_func"() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW."connectionAliveAt" <> OLD."connectionAliveAt" THEN
|
||||
NEW."userClientResponseAt" := NULL;
|
||||
NEW."applicationRttInMs" := NULL;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "update_user_connectionStatus_connectionAliveAt_trigger" BEFORE UPDATE OF "connectionAliveAt" ON "user_connectionStatus"
|
||||
FOR EACH ROW EXECUTE FUNCTION "update_user_connectionStatus_connectionAliveAt_trigger_func"();
|
||||
|
||||
|
||||
CREATE OR REPLACE VIEW "v_user_connectionStatusReport" AS
|
||||
SELECT u."meetingId", u."userId",
|
||||
max(cs."connectionAliveAt") AS "connectionAliveAt",
|
||||
max(cs."status") AS "currentStatus",
|
||||
--COALESCE(max(cs."applicationRttInMs"),(EXTRACT(EPOCH FROM (current_timestamp - max(cs."connectionAliveAt"))) * 1000)) AS "applicationRttInMs",
|
||||
CASE WHEN max(cs."connectionAliveAt") < current_timestamp - INTERVAL '12 seconds' THEN TRUE ELSE FALSE END AS "clientNotResponding",
|
||||
CASE WHEN max(cs."connectionAliveAt") < current_timestamp - INTERVAL '1 millisecond' * max(cs."connectionAliveAtMaxIntervalMs") THEN TRUE ELSE FALSE END AS "clientNotResponding",
|
||||
(array_agg(csm."status" ORDER BY csm."lastOccurrenceAt" DESC))[1] as "lastUnstableStatus",
|
||||
max(csm."lastOccurrenceAt") AS "lastUnstableStatusAt"
|
||||
FROM "user" u
|
||||
@ -870,6 +854,20 @@ FROM "user" u
|
||||
JOIN "user_reaction" ur ON u."userId" = ur."userId" AND "expiresAt" > current_timestamp
|
||||
GROUP BY u."meetingId", ur."userId";
|
||||
|
||||
CREATE TABLE "user_transcriptionError"(
|
||||
"userId" varchar(50) PRIMARY KEY REFERENCES "user"("userId") ON DELETE CASCADE,
|
||||
"meetingId" varchar(100) references "meeting"("meetingId") ON DELETE CASCADE,
|
||||
"errorCode" varchar(255),
|
||||
"errorMessage" text,
|
||||
"lastUpdatedAt" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_user_transcriptionError_meetingId" ON "user_transcriptionError"("meetingId");
|
||||
CREATE INDEX "idx_user_transcriptionError_userId" ON "user_transcriptionError"("userId");
|
||||
|
||||
create view "v_user_transcriptionError" as select * from "user_transcriptionError";
|
||||
|
||||
|
||||
|
||||
|
||||
create view "v_meeting" as
|
||||
|
@ -460,11 +460,7 @@ type Mutation {
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
userSetConnectionAlive: Boolean
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
userSetConnectionRtt(
|
||||
userSetConnectionAlive(
|
||||
networkRttInMs: Float!
|
||||
): Boolean
|
||||
}
|
||||
|
@ -421,12 +421,6 @@ actions:
|
||||
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
|
||||
permissions:
|
||||
- role: bbb_client
|
||||
- name: userSetConnectionRtt
|
||||
definition:
|
||||
kind: synchronous
|
||||
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
|
||||
permissions:
|
||||
- role: bbb_client
|
||||
- name: userSetEmojiStatus
|
||||
definition:
|
||||
kind: synchronous
|
||||
|
@ -22,11 +22,9 @@ select_permissions:
|
||||
columns:
|
||||
- connectionAliveAt
|
||||
- meetingId
|
||||
- applicationRttInMs
|
||||
- networkRttInMs
|
||||
- status
|
||||
- statusUpdatedAt
|
||||
- userClientResponseAt
|
||||
- userId
|
||||
filter:
|
||||
_and:
|
||||
|
@ -70,6 +70,15 @@ object_relationships:
|
||||
remote_table:
|
||||
name: v_sharedNotes_session
|
||||
schema: public
|
||||
- name: transcriptionError
|
||||
using:
|
||||
manual_configuration:
|
||||
column_mapping:
|
||||
userId: userId
|
||||
insertion_order: null
|
||||
remote_table:
|
||||
name: v_user_transcriptionError
|
||||
schema: public
|
||||
- name: userClientSettings
|
||||
using:
|
||||
manual_configuration:
|
||||
|
@ -0,0 +1,19 @@
|
||||
table:
|
||||
name: v_user_transcriptionError
|
||||
schema: public
|
||||
configuration:
|
||||
column_config: {}
|
||||
custom_column_names: {}
|
||||
custom_name: user_transcriptionError
|
||||
custom_root_fields: {}
|
||||
select_permissions:
|
||||
- role: bbb_client
|
||||
permission:
|
||||
columns:
|
||||
- errorCode
|
||||
- errorMessage
|
||||
- lastUpdatedAt
|
||||
filter:
|
||||
userId:
|
||||
_eq: X-Hasura-UserId
|
||||
comment: ""
|
@ -54,6 +54,7 @@
|
||||
- "!include public_v_user_reaction.yaml"
|
||||
- "!include public_v_user_reaction_current.yaml"
|
||||
- "!include public_v_user_ref.yaml"
|
||||
- "!include public_v_user_transcriptionError.yaml"
|
||||
- "!include public_v_user_typing_private.yaml"
|
||||
- "!include public_v_user_typing_public.yaml"
|
||||
- "!include public_v_user_voice.yaml"
|
||||
|
@ -1 +1 @@
|
||||
git clone --branch v0.1.0 --depth 1 https://github.com/bigbluebutton/bbb-transcription-controller bbb-transcription-controller
|
||||
git clone --branch v0.2.3 --depth 1 https://github.com/bigbluebutton/bbb-transcription-controller bbb-transcription-controller
|
||||
|
@ -1559,7 +1559,7 @@ if [ -n "$HOST" ]; then
|
||||
sudo yq e -i ".freeswitch.esl_password = \"$ESL_PASSWORD\"" $WEBRTC_SFU_ETC_CONFIG
|
||||
sudo xmlstarlet edit --inplace --update 'configuration/settings//param[@name="password"]/@value' --value $ESL_PASSWORD /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml
|
||||
if [ -f /usr/local/bigbluebutton/bbb-transcription-controller/config/default.yml ]; then
|
||||
sudo yq w -i /usr/local/bigbluebutton/bbb-transcription-controller/config/default.yml freeswitch.esl_password "$ESL_PASSWORD"
|
||||
sudo yq w -i /usr/local/bigbluebutton/bbb-transcription-controller/config/default.yml freeswitch.password "$ESL_PASSWORD"
|
||||
fi
|
||||
|
||||
echo "Restarting BigBlueButton $BIGBLUEBUTTON_RELEASE ..."
|
||||
|
@ -258,32 +258,19 @@ export default withTracker(() => {
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
const currentMeeting = Meetings.findOne({ meetingId: Auth.meetingID },
|
||||
{
|
||||
fields: {
|
||||
randomlySelectedUser: 1,
|
||||
layout: 1,
|
||||
},
|
||||
});
|
||||
const {
|
||||
randomlySelectedUser,
|
||||
} = currentMeeting;
|
||||
|
||||
const meetingLayoutObj = Meetings
|
||||
.findOne({ meetingId: Auth.meetingID }) || {};
|
||||
|
||||
const { layout } = meetingLayoutObj;
|
||||
|
||||
const {
|
||||
currentLayoutType: meetingLayout,
|
||||
propagateLayout: pushLayoutMeeting,
|
||||
cameraDockIsResizing: isMeetingLayoutResizing,
|
||||
cameraDockPlacement: meetingLayoutCameraPosition,
|
||||
presentationVideoRate: meetingLayoutVideoRate,
|
||||
cameraDockAspectRatio: meetingLayoutVideoRate,
|
||||
cameraWithFocus: meetingLayoutFocusedCamera,
|
||||
} = layout;
|
||||
|
||||
const meetingLayout = LAYOUT_TYPE[layout.currentLayoutType];
|
||||
const meetingLayoutUpdatedAt = new Date(layout.updatedAt).getTime();
|
||||
|
||||
const meetingPresentationIsOpen = !layout.presentationMinimized;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Session } from 'meteor/session';
|
||||
import {
|
||||
@ -13,6 +13,7 @@ import AudioDial from '../audio-dial/component';
|
||||
import AudioAutoplayPrompt from '../autoplay/component';
|
||||
import Settings from '/imports/ui/services/settings';
|
||||
import CaptionsSelectContainer from '/imports/ui/components/audio/captions/select/container';
|
||||
import { usePreviousValue } from '../../utils/hooks';
|
||||
|
||||
const propTypes = {
|
||||
intl: PropTypes.shape({
|
||||
@ -48,6 +49,14 @@ const propTypes = {
|
||||
localEchoEnabled: PropTypes.bool.isRequired,
|
||||
showVolumeMeter: PropTypes.bool.isRequired,
|
||||
notify: PropTypes.func.isRequired,
|
||||
isRTL: PropTypes.bool.isRequired,
|
||||
priority: PropTypes.string.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
setIsOpen: PropTypes.func.isRequired,
|
||||
AudioError: PropTypes.shape({
|
||||
MIC_ERROR: PropTypes.number.isRequired,
|
||||
NO_SSL: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
@ -124,288 +133,169 @@ const intlMessages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
class AudioModal extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const AudioModal = (props) => {
|
||||
const [content, setContent] = useState(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [disableActions, setDisableActions] = useState(false);
|
||||
const [errCode, setErrCode] = useState(null);
|
||||
const [autoplayChecked, setAutoplayChecked] = useState(false);
|
||||
|
||||
this.state = {
|
||||
content: null,
|
||||
hasError: false,
|
||||
errCode: null,
|
||||
};
|
||||
const {
|
||||
forceListenOnlyAttendee,
|
||||
joinFullAudioImmediately,
|
||||
listenOnlyMode,
|
||||
audioLocked,
|
||||
isUsingAudio,
|
||||
autoplayBlocked,
|
||||
closeModal,
|
||||
isEchoTest,
|
||||
exitAudio,
|
||||
resolve,
|
||||
leaveEchoTest,
|
||||
AudioError,
|
||||
joinEchoTest,
|
||||
isConnecting,
|
||||
localEchoEnabled,
|
||||
joinListenOnly,
|
||||
changeInputStream,
|
||||
joinMicrophone,
|
||||
intl,
|
||||
isMobileNative,
|
||||
formattedDialNum,
|
||||
isRTL,
|
||||
isConnected,
|
||||
inputDeviceId,
|
||||
outputDeviceId,
|
||||
changeInputDevice,
|
||||
changeOutputDevice,
|
||||
showVolumeMeter,
|
||||
notify,
|
||||
formattedTelVoice,
|
||||
handleAllowAutoplay,
|
||||
showPermissionsOvelay,
|
||||
isIE,
|
||||
isOpen,
|
||||
priority,
|
||||
setIsOpen,
|
||||
} = props;
|
||||
|
||||
this.handleGoToAudioOptions = this.handleGoToAudioOptions.bind(this);
|
||||
this.handleGoToAudioSettings = this.handleGoToAudioSettings.bind(this);
|
||||
this.handleRetryGoToEchoTest = this.handleRetryGoToEchoTest.bind(this);
|
||||
this.handleGoToEchoTest = this.handleGoToEchoTest.bind(this);
|
||||
this.handleJoinMicrophone = this.handleJoinMicrophone.bind(this);
|
||||
this.handleJoinLocalEcho = this.handleJoinLocalEcho.bind(this);
|
||||
this.handleJoinListenOnly = this.handleJoinListenOnly.bind(this);
|
||||
this.skipAudioOptions = this.skipAudioOptions.bind(this);
|
||||
const prevAutoplayBlocked = usePreviousValue(autoplayBlocked);
|
||||
|
||||
this.contents = {
|
||||
echoTest: {
|
||||
title: intlMessages.echoTestTitle,
|
||||
component: () => this.renderEchoTest(),
|
||||
},
|
||||
settings: {
|
||||
title: intlMessages.settingsTitle,
|
||||
component: () => this.renderAudioSettings(),
|
||||
},
|
||||
help: {
|
||||
title: intlMessages.helpTitle,
|
||||
component: () => this.renderHelp(),
|
||||
},
|
||||
audioDial: {
|
||||
title: intlMessages.audioDialTitle,
|
||||
component: () => this.renderAudioDial(),
|
||||
},
|
||||
autoplayBlocked: {
|
||||
title: intlMessages.autoplayPromptTitle,
|
||||
component: () => this.renderAutoplayOverlay(),
|
||||
},
|
||||
};
|
||||
this.failedMediaElements = [];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
forceListenOnlyAttendee,
|
||||
joinFullAudioImmediately,
|
||||
listenOnlyMode,
|
||||
audioLocked,
|
||||
isUsingAudio,
|
||||
} = this.props;
|
||||
|
||||
if (!isUsingAudio) {
|
||||
if (forceListenOnlyAttendee || audioLocked) return this.handleJoinListenOnly();
|
||||
|
||||
if (joinFullAudioImmediately && !listenOnlyMode) return this.handleJoinMicrophone();
|
||||
|
||||
if (!listenOnlyMode) return this.handleGoToEchoTest();
|
||||
useEffect(() => {
|
||||
if (prevAutoplayBlocked && !autoplayBlocked) {
|
||||
setAutoplayChecked(true);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}, [autoplayBlocked]);
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { autoplayBlocked, closeModal } = this.props;
|
||||
|
||||
if (autoplayBlocked !== prevProps.autoplayBlocked) {
|
||||
if (autoplayBlocked) {
|
||||
this.setContent({ content: 'autoplayBlocked' });
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
const handleJoinMicrophoneError = (err) => {
|
||||
const { type } = err;
|
||||
switch (type) {
|
||||
case 'MEDIA_ERROR':
|
||||
setContent('help');
|
||||
setErrCode(0);
|
||||
setDisableActions(false);
|
||||
break;
|
||||
case 'CONNECTION_ERROR':
|
||||
default:
|
||||
setErrCode(0);
|
||||
setDisableActions(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
const {
|
||||
isEchoTest,
|
||||
exitAudio,
|
||||
resolve,
|
||||
} = this.props;
|
||||
|
||||
if (isEchoTest) {
|
||||
exitAudio();
|
||||
}
|
||||
if (resolve) resolve();
|
||||
Session.set('audioModalIsOpen', false);
|
||||
}
|
||||
|
||||
handleGoToAudioOptions() {
|
||||
this.setState({
|
||||
content: null,
|
||||
hasError: true,
|
||||
disableActions: false,
|
||||
});
|
||||
}
|
||||
|
||||
handleGoToAudioSettings() {
|
||||
const { leaveEchoTest } = this.props;
|
||||
leaveEchoTest().then(() => {
|
||||
this.setState({
|
||||
content: 'settings',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleRetryGoToEchoTest() {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
content: null,
|
||||
});
|
||||
|
||||
return this.handleGoToEchoTest();
|
||||
}
|
||||
|
||||
handleGoToLocalEcho() {
|
||||
const handleGoToLocalEcho = () => {
|
||||
// Simplified echo test: this will return the AudioSettings with:
|
||||
// - withEcho: true
|
||||
// Echo test will be local and done in the AudioSettings view instead of the
|
||||
// old E2E -> yes/no -> join view
|
||||
this.setState({
|
||||
content: 'settings',
|
||||
});
|
||||
}
|
||||
setContent('settings');
|
||||
};
|
||||
|
||||
handleGoToEchoTest() {
|
||||
const { AudioError } = this.props;
|
||||
const handleGoToEchoTest = () => {
|
||||
const { MIC_ERROR } = AudioError;
|
||||
const noSSL = !window.location.protocol.includes('https');
|
||||
|
||||
if (noSSL) {
|
||||
return this.setState({
|
||||
content: 'help',
|
||||
errCode: MIC_ERROR.NO_SSL,
|
||||
});
|
||||
setContent('help');
|
||||
setErrCode(MIC_ERROR.NO_SSL);
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
joinEchoTest,
|
||||
isConnecting,
|
||||
localEchoEnabled,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
disableActions,
|
||||
} = this.state;
|
||||
|
||||
if (disableActions && isConnecting) return null;
|
||||
|
||||
if (localEchoEnabled) return this.handleGoToLocalEcho();
|
||||
if (localEchoEnabled) return handleGoToLocalEcho();
|
||||
|
||||
this.setState({
|
||||
hasError: false,
|
||||
disableActions: true,
|
||||
});
|
||||
setHasError(false);
|
||||
setDisableActions(true);
|
||||
|
||||
return joinEchoTest().then(() => {
|
||||
this.setState({
|
||||
content: 'echoTest',
|
||||
disableActions: false,
|
||||
});
|
||||
setContent('echoTest');
|
||||
setDisableActions(true);
|
||||
}).catch((err) => {
|
||||
this.handleJoinMicrophoneError(err);
|
||||
handleJoinMicrophoneError(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleJoinListenOnly() {
|
||||
const {
|
||||
joinListenOnly,
|
||||
isConnecting,
|
||||
} = this.props;
|
||||
const handleGoToAudioOptions = () => {
|
||||
setContent(null);
|
||||
setHasError(true);
|
||||
setDisableActions(false);
|
||||
};
|
||||
|
||||
const {
|
||||
disableActions,
|
||||
} = this.state;
|
||||
const handleGoToAudioSettings = () => {
|
||||
leaveEchoTest().then(() => {
|
||||
setContent('settings');
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetryGoToEchoTest = () => {
|
||||
setHasError(false);
|
||||
setContent(null);
|
||||
|
||||
return handleGoToEchoTest();
|
||||
};
|
||||
|
||||
const handleJoinListenOnly = () => {
|
||||
if (disableActions && isConnecting) return null;
|
||||
|
||||
this.setState({
|
||||
disableActions: true,
|
||||
});
|
||||
setDisableActions(true);
|
||||
|
||||
return joinListenOnly().then(() => {
|
||||
this.setState({
|
||||
disableActions: false,
|
||||
});
|
||||
setDisableActions(false);
|
||||
}).catch((err) => {
|
||||
if (err.type === 'MEDIA_ERROR') {
|
||||
this.setState({
|
||||
content: 'help',
|
||||
});
|
||||
setContent('help');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleJoinLocalEcho(inputStream) {
|
||||
const { changeInputStream } = this.props;
|
||||
// Reset the modal to a connecting state - this kind of sucks?
|
||||
// prlanzarin Apr 04 2022
|
||||
this.setState({
|
||||
content: null,
|
||||
});
|
||||
if (inputStream) changeInputStream(inputStream);
|
||||
this.handleJoinMicrophone();
|
||||
}
|
||||
|
||||
handleJoinMicrophone() {
|
||||
const {
|
||||
joinMicrophone,
|
||||
isConnecting,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
disableActions,
|
||||
} = this.state;
|
||||
};
|
||||
|
||||
const handleJoinMicrophone = () => {
|
||||
if (disableActions && isConnecting) return;
|
||||
|
||||
this.setState({
|
||||
hasError: false,
|
||||
disableActions: true,
|
||||
});
|
||||
setHasError(false);
|
||||
setDisableActions(true);
|
||||
|
||||
joinMicrophone().then(() => {
|
||||
this.setState({
|
||||
disableActions: false,
|
||||
});
|
||||
setDisableActions(false);
|
||||
}).catch((err) => {
|
||||
this.handleJoinMicrophoneError(err);
|
||||
handleJoinMicrophoneError(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleJoinMicrophoneError(err) {
|
||||
const { type } = err;
|
||||
switch (type) {
|
||||
case 'MEDIA_ERROR':
|
||||
this.setState({
|
||||
content: 'help',
|
||||
errCode: 0,
|
||||
disableActions: false,
|
||||
});
|
||||
break;
|
||||
case 'CONNECTION_ERROR':
|
||||
default:
|
||||
this.setState({
|
||||
errCode: 0,
|
||||
disableActions: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
const handleJoinLocalEcho = (inputStream) => {
|
||||
// Reset the modal to a connecting state - this kind of sucks?
|
||||
// prlanzarin Apr 04 2022
|
||||
setContent(null);
|
||||
if (inputStream) changeInputStream(inputStream);
|
||||
handleJoinMicrophone();
|
||||
};
|
||||
|
||||
setContent(content) {
|
||||
this.setState(content);
|
||||
}
|
||||
const skipAudioOptions = () => (isConnecting || (forceListenOnlyAttendee && !autoplayChecked))
|
||||
&& !content
|
||||
&& !hasError;
|
||||
|
||||
skipAudioOptions() {
|
||||
const {
|
||||
isConnecting,
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
content,
|
||||
hasError,
|
||||
} = this.state;
|
||||
|
||||
return isConnecting && !content && !hasError;
|
||||
}
|
||||
|
||||
renderAudioOptions() {
|
||||
const {
|
||||
intl,
|
||||
listenOnlyMode,
|
||||
forceListenOnlyAttendee,
|
||||
joinFullAudioImmediately,
|
||||
audioLocked,
|
||||
isMobileNative,
|
||||
formattedDialNum,
|
||||
isRTL,
|
||||
} = this.props;
|
||||
|
||||
const showMicrophone = forceListenOnlyAttendee || audioLocked;
|
||||
const renderAudioOptions = () => {
|
||||
const hideMicrophone = forceListenOnlyAttendee || audioLocked;
|
||||
|
||||
const arrow = isRTL ? '←' : '→';
|
||||
const dialAudioLabel = `${intl.formatMessage(intlMessages.audioDialTitle)} ${arrow}`;
|
||||
@ -413,45 +303,43 @@ class AudioModal extends Component {
|
||||
return (
|
||||
<div>
|
||||
<Styled.AudioOptions data-test="audioModalOptions">
|
||||
{!showMicrophone && !isMobileNative
|
||||
&& (
|
||||
<>
|
||||
<Styled.AudioModalButton
|
||||
label={intl.formatMessage(intlMessages.microphoneLabel)}
|
||||
data-test="microphoneBtn"
|
||||
aria-describedby="mic-description"
|
||||
icon="unmute"
|
||||
circle
|
||||
size="jumbo"
|
||||
disabled={audioLocked}
|
||||
onClick={
|
||||
joinFullAudioImmediately
|
||||
? this.handleJoinMicrophone
|
||||
: this.handleGoToEchoTest
|
||||
}
|
||||
/>
|
||||
<span className="sr-only" id="mic-description">
|
||||
{intl.formatMessage(intlMessages.microphoneDesc)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{listenOnlyMode
|
||||
&& (
|
||||
<>
|
||||
<Styled.AudioModalButton
|
||||
label={intl.formatMessage(intlMessages.listenOnlyLabel)}
|
||||
data-test="listenOnlyBtn"
|
||||
aria-describedby="listenOnly-description"
|
||||
icon="listen"
|
||||
circle
|
||||
size="jumbo"
|
||||
onClick={this.handleJoinListenOnly}
|
||||
/>
|
||||
<span className="sr-only" id="listenOnly-description">
|
||||
{intl.formatMessage(intlMessages.listenOnlyDesc)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!hideMicrophone && !isMobileNative && (
|
||||
<>
|
||||
<Styled.AudioModalButton
|
||||
label={intl.formatMessage(intlMessages.microphoneLabel)}
|
||||
data-test="microphoneBtn"
|
||||
aria-describedby="mic-description"
|
||||
icon="unmute"
|
||||
circle
|
||||
size="jumbo"
|
||||
disabled={audioLocked}
|
||||
onClick={
|
||||
joinFullAudioImmediately
|
||||
? handleJoinMicrophone
|
||||
: handleGoToEchoTest
|
||||
}
|
||||
/>
|
||||
<span className="sr-only" id="mic-description">
|
||||
{intl.formatMessage(intlMessages.microphoneDesc)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{listenOnlyMode && (
|
||||
<>
|
||||
<Styled.AudioModalButton
|
||||
label={intl.formatMessage(intlMessages.listenOnlyLabel)}
|
||||
data-test="listenOnlyBtn"
|
||||
aria-describedby="listenOnly-description"
|
||||
icon="listen"
|
||||
circle
|
||||
size="jumbo"
|
||||
onClick={handleJoinListenOnly}
|
||||
/>
|
||||
<span className="sr-only" id="listenOnly-description">
|
||||
{intl.formatMessage(intlMessages.listenOnlyDesc)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Styled.AudioOptions>
|
||||
{formattedDialNum ? (
|
||||
<Styled.AudioDial
|
||||
@ -459,78 +347,36 @@ class AudioModal extends Component {
|
||||
size="md"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
this.setState({
|
||||
content: 'audioDial',
|
||||
});
|
||||
setContent('audioDial');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<CaptionsSelectContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderContent() {
|
||||
const {
|
||||
isEchoTest,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
const { content } = this.state;
|
||||
const { animations } = Settings.application;
|
||||
|
||||
if (this.skipAudioOptions()) {
|
||||
return (
|
||||
<Styled.Connecting role="alert">
|
||||
<span data-test={!isEchoTest ? 'establishingAudioLabel' : 'connectingToEchoTest'}>
|
||||
{intl.formatMessage(intlMessages.connecting)}
|
||||
</span>
|
||||
<Styled.ConnectingAnimation animations={animations} />
|
||||
</Styled.Connecting>
|
||||
);
|
||||
}
|
||||
return content ? this.contents[content].component() : this.renderAudioOptions();
|
||||
}
|
||||
|
||||
renderEchoTest() {
|
||||
return (
|
||||
<EchoTest
|
||||
handleNo={this.handleGoToAudioSettings}
|
||||
handleYes={this.handleJoinMicrophone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderAudioSettings() {
|
||||
const {
|
||||
isConnecting,
|
||||
isConnected,
|
||||
isEchoTest,
|
||||
inputDeviceId,
|
||||
outputDeviceId,
|
||||
joinEchoTest,
|
||||
changeInputDevice,
|
||||
changeOutputDevice,
|
||||
localEchoEnabled,
|
||||
showVolumeMeter,
|
||||
notify,
|
||||
} = this.props;
|
||||
const renderEchoTest = () => (
|
||||
<EchoTest
|
||||
handleNo={handleGoToAudioSettings}
|
||||
handleYes={handleJoinMicrophone}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderAudioSettings = () => {
|
||||
const confirmationCallback = !localEchoEnabled
|
||||
? this.handleRetryGoToEchoTest
|
||||
: this.handleJoinLocalEcho;
|
||||
? handleRetryGoToEchoTest
|
||||
: handleJoinLocalEcho;
|
||||
|
||||
const handleGUMFailure = () => {
|
||||
this.setState({
|
||||
content: 'help',
|
||||
errCode: 0,
|
||||
disableActions: false,
|
||||
});
|
||||
setContent('help');
|
||||
setErrCode(0);
|
||||
setDisableActions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AudioSettings
|
||||
handleBack={this.handleGoToAudioOptions}
|
||||
handleBack={handleGoToAudioOptions}
|
||||
handleConfirmation={confirmationCallback}
|
||||
handleGUMFailure={handleGUMFailure}
|
||||
joinEchoTest={joinEchoTest}
|
||||
@ -547,12 +393,9 @@ class AudioModal extends Component {
|
||||
notify={notify}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderHelp() {
|
||||
const { errCode } = this.state;
|
||||
const { AudioError } = this.props;
|
||||
};
|
||||
|
||||
const renderHelp = () => {
|
||||
const audioErr = {
|
||||
...AudioError,
|
||||
code: errCode,
|
||||
@ -560,88 +403,138 @@ class AudioModal extends Component {
|
||||
|
||||
return (
|
||||
<Help
|
||||
handleBack={this.handleGoToAudioOptions}
|
||||
handleBack={handleGoToAudioOptions}
|
||||
audioErr={audioErr}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderAudioDial() {
|
||||
const { formattedDialNum, formattedTelVoice } = this.props;
|
||||
return (
|
||||
<AudioDial
|
||||
formattedDialNum={formattedDialNum}
|
||||
telVoice={formattedTelVoice}
|
||||
handleBack={this.handleGoToAudioOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const renderAudioDial = () => (
|
||||
<AudioDial
|
||||
formattedDialNum={formattedDialNum}
|
||||
telVoice={formattedTelVoice}
|
||||
handleBack={handleGoToAudioOptions}
|
||||
/>
|
||||
);
|
||||
|
||||
renderAutoplayOverlay() {
|
||||
const { handleAllowAutoplay } = this.props;
|
||||
return (
|
||||
<AudioAutoplayPrompt
|
||||
handleAllowAutoplay={handleAllowAutoplay}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const renderAutoplayOverlay = () => (
|
||||
<AudioAutoplayPrompt
|
||||
handleAllowAutoplay={handleAllowAutoplay}
|
||||
/>
|
||||
);
|
||||
|
||||
render() {
|
||||
const {
|
||||
intl,
|
||||
showPermissionsOvelay,
|
||||
closeModal,
|
||||
isIE,
|
||||
isOpen,
|
||||
priority,
|
||||
setIsOpen,
|
||||
} = this.props;
|
||||
const contents = {
|
||||
echoTest: {
|
||||
title: intlMessages.echoTestTitle,
|
||||
component: renderEchoTest,
|
||||
},
|
||||
settings: {
|
||||
title: intlMessages.settingsTitle,
|
||||
component: renderAudioSettings,
|
||||
},
|
||||
help: {
|
||||
title: intlMessages.helpTitle,
|
||||
component: renderHelp,
|
||||
},
|
||||
audioDial: {
|
||||
title: intlMessages.audioDialTitle,
|
||||
component: renderAudioDial,
|
||||
},
|
||||
autoplayBlocked: {
|
||||
title: intlMessages.autoplayPromptTitle,
|
||||
component: renderAutoplayOverlay,
|
||||
},
|
||||
};
|
||||
|
||||
const { content } = this.state;
|
||||
const renderContent = () => {
|
||||
const { animations } = Settings.application;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPermissionsOvelay ? <PermissionsOverlay closeModal={closeModal} /> : null}
|
||||
<Styled.AudioModal
|
||||
modalName="AUDIO"
|
||||
onRequestClose={closeModal}
|
||||
data-test="audioModal"
|
||||
contentLabel={intl.formatMessage(intlMessages.ariaModalTitle)}
|
||||
title={
|
||||
!this.skipAudioOptions()
|
||||
? (
|
||||
content
|
||||
? intl.formatMessage(this.contents[content].title)
|
||||
: intl.formatMessage(intlMessages.audioChoiceLabel)
|
||||
)
|
||||
: null
|
||||
}
|
||||
{...{
|
||||
setIsOpen,
|
||||
isOpen,
|
||||
priority,
|
||||
}}
|
||||
>
|
||||
{isIE ? (
|
||||
<Styled.BrowserWarning>
|
||||
<FormattedMessage
|
||||
id="app.audioModal.unsupportedBrowserLabel"
|
||||
description="Warning when someone joins with a browser that isn't supported"
|
||||
values={{
|
||||
0: <a href="https://www.google.com/chrome/">Chrome</a>,
|
||||
1: <a href="https://getfirefox.com">Firefox</a>,
|
||||
}}
|
||||
/>
|
||||
</Styled.BrowserWarning>
|
||||
) : null}
|
||||
<Styled.Content>
|
||||
{this.renderContent()}
|
||||
</Styled.Content>
|
||||
</Styled.AudioModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (skipAudioOptions()) {
|
||||
return (
|
||||
<Styled.Connecting role="alert">
|
||||
<span data-test={!isEchoTest ? 'establishingAudioLabel' : 'connectingToEchoTest'}>
|
||||
{intl.formatMessage(intlMessages.connecting)}
|
||||
</span>
|
||||
<Styled.ConnectingAnimation animations={animations} />
|
||||
</Styled.Connecting>
|
||||
);
|
||||
}
|
||||
return content ? contents[content].component() : renderAudioOptions();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUsingAudio) {
|
||||
if (forceListenOnlyAttendee || audioLocked) {
|
||||
handleJoinListenOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
if (joinFullAudioImmediately && !listenOnlyMode) {
|
||||
handleJoinMicrophone();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!listenOnlyMode) {
|
||||
handleGoToEchoTest();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoplayBlocked) {
|
||||
setContent('autoplayBlocked');
|
||||
} else if (prevAutoplayBlocked) {
|
||||
closeModal();
|
||||
}
|
||||
}, [autoplayBlocked]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (isEchoTest) {
|
||||
exitAudio();
|
||||
}
|
||||
if (resolve) resolve();
|
||||
Session.set('audioModalIsOpen', false);
|
||||
}, []);
|
||||
|
||||
let title = content
|
||||
? intl.formatMessage(contents[content].title)
|
||||
: intl.formatMessage(intlMessages.audioChoiceLabel);
|
||||
title = !skipAudioOptions() ? title : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPermissionsOvelay ? <PermissionsOverlay closeModal={closeModal} /> : null}
|
||||
<Styled.AudioModal
|
||||
modalName="AUDIO"
|
||||
onRequestClose={closeModal}
|
||||
data-test="audioModal"
|
||||
contentLabel={intl.formatMessage(intlMessages.ariaModalTitle)}
|
||||
title={title}
|
||||
{...{
|
||||
setIsOpen,
|
||||
isOpen,
|
||||
priority,
|
||||
}}
|
||||
>
|
||||
{isIE ? (
|
||||
<Styled.BrowserWarning>
|
||||
<FormattedMessage
|
||||
id="app.audioModal.unsupportedBrowserLabel"
|
||||
description="Warning when someone joins with a browser that isn't supported"
|
||||
values={{
|
||||
0: <a href="https://www.google.com/chrome/">Chrome</a>,
|
||||
1: <a href="https://getfirefox.com">Firefox</a>,
|
||||
}}
|
||||
/>
|
||||
</Styled.BrowserWarning>
|
||||
) : null}
|
||||
<Styled.Content>
|
||||
{renderContent()}
|
||||
</Styled.Content>
|
||||
</Styled.AudioModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AudioModal.propTypes = propTypes;
|
||||
AudioModal.defaultProps = defaultProps;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useSubscription } from '@apollo/client';
|
||||
import { isEqual } from 'radash';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
@ -87,13 +87,30 @@ const ChatAlertGraphql: React.FC<ChatAlertGraphqlProps> = (props) => {
|
||||
const prevPrivateUnreadMessages = usePreviousValue(privateUnreadMessages);
|
||||
const publicMessagesDidChange = !isEqual(prevPublicUnreadMessages, publicUnreadMessages);
|
||||
const privateMessagesDidChange = !isEqual(prevPrivateUnreadMessages, privateUnreadMessages);
|
||||
const shouldRenderPublicChatAlerts = publicMessagesDidChange && publicUnreadMessages;
|
||||
const shouldRenderPrivateChatAlerts = privateMessagesDidChange && privateUnreadMessages;
|
||||
const shouldRenderPublicChatAlerts = publicMessagesDidChange
|
||||
&& !!publicUnreadMessages
|
||||
&& publicUnreadMessages.length > 0;
|
||||
const shouldRenderPrivateChatAlerts = privateMessagesDidChange
|
||||
&& !!privateUnreadMessages
|
||||
&& privateUnreadMessages.length > 0;
|
||||
const shouldPlayAudioAlert = useCallback(
|
||||
(m: Message) => m.chatId !== idChatOpen && !history.current.has(m.messageId),
|
||||
(m: Message) => (m.chatId !== idChatOpen || document.hidden) && !history.current.has(m.messageId),
|
||||
[idChatOpen, history.current],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRenderPublicChatAlerts) {
|
||||
publicUnreadMessages.forEach((m) => {
|
||||
history.current.add(m.messageId);
|
||||
});
|
||||
}
|
||||
if (shouldRenderPrivateChatAlerts) {
|
||||
privateUnreadMessages.forEach((m) => {
|
||||
history.current.add(m.messageId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let playAudioAlert = false;
|
||||
|
||||
if (shouldRenderPublicChatAlerts) {
|
||||
@ -102,7 +119,6 @@ const ChatAlertGraphql: React.FC<ChatAlertGraphqlProps> = (props) => {
|
||||
if (shouldRenderPrivateChatAlerts && !playAudioAlert) {
|
||||
playAudioAlert = privateUnreadMessages.some(shouldPlayAudioAlert);
|
||||
}
|
||||
playAudioAlert ||= document.hidden;
|
||||
|
||||
if (audioAlertEnabled && playAudioAlert) {
|
||||
Service.playAlertSound();
|
||||
@ -138,7 +154,6 @@ const ChatAlertGraphql: React.FC<ChatAlertGraphqlProps> = (props) => {
|
||||
|
||||
const renderToast = (message: Message) => {
|
||||
if (history.current.has(message.messageId)) return null;
|
||||
history.current.add(message.messageId);
|
||||
if (message.chatId === idChatOpen) return null;
|
||||
|
||||
const messageChatId = message.chatId === PUBLIC_GROUP_CHAT_ID ? PUBLIC_CHAT_ID : message.chatId;
|
||||
|
@ -1,30 +1,22 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMutation, useSubscription } from '@apollo/client';
|
||||
import { CONNECTION_STATUS_SUBSCRIPTION } from './queries';
|
||||
import { UPDATE_CONNECTION_ALIVE_AT, UPDATE_USER_CLIENT_RTT } from './mutations';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { UPDATE_CONNECTION_ALIVE_AT } from './mutations';
|
||||
|
||||
const STATS_INTERVAL = window.meetingClientSettings.public.stats.interval;
|
||||
|
||||
const ConnectionStatus = () => {
|
||||
const networkRttInMs = useRef(null); // Ref to store the current timeout
|
||||
const lastStatusUpdatedAtReceived = useRef(null); // Ref to store the current timeout
|
||||
const networkRttInMs = useRef(0); // Ref to store the last rtt
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
const [updateUserClientRtt] = useMutation(UPDATE_USER_CLIENT_RTT);
|
||||
|
||||
const handleUpdateUserClientResponseAt = () => {
|
||||
updateUserClientRtt({
|
||||
variables: {
|
||||
networkRttInMs: networkRttInMs.current,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const [updateConnectionAliveAtToMeAsNow] = useMutation(UPDATE_CONNECTION_ALIVE_AT);
|
||||
const [updateConnectionAliveAtM] = useMutation(UPDATE_CONNECTION_ALIVE_AT);
|
||||
|
||||
const handleUpdateConnectionAliveAt = () => {
|
||||
const startTime = performance.now();
|
||||
updateConnectionAliveAtToMeAsNow().then(() => {
|
||||
updateConnectionAliveAtM({
|
||||
variables: {
|
||||
networkRttInMs: networkRttInMs.current,
|
||||
},
|
||||
}).then(() => {
|
||||
const endTime = performance.now();
|
||||
networkRttInMs.current = endTime - startTime;
|
||||
}).finally(() => {
|
||||
@ -39,27 +31,13 @@ const ConnectionStatus = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleUpdateConnectionAliveAt();
|
||||
// Delay first connectionAlive to avoid high RTT misestimation
|
||||
// due to initial subscription and mutation traffic at client render
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
handleUpdateConnectionAliveAt();
|
||||
}, STATS_INTERVAL / 2);
|
||||
}, []);
|
||||
|
||||
const { loading, error, data } = useSubscription(CONNECTION_STATUS_SUBSCRIPTION);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !error && data) {
|
||||
data.user_connectionStatus.forEach((curr) => {
|
||||
if (curr.connectionAliveAt != null
|
||||
&& curr.userClientResponseAt == null
|
||||
&& (curr.statusUpdatedAt == null
|
||||
|| curr.statusUpdatedAt !== lastStatusUpdatedAtReceived.current
|
||||
)
|
||||
) {
|
||||
lastStatusUpdatedAtReceived.current = curr.statusUpdatedAt;
|
||||
handleUpdateUserClientResponseAt();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
@ -1,18 +1,12 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_CONNECTION_ALIVE_AT = gql`
|
||||
mutation UpdateConnectionAliveAt {
|
||||
userSetConnectionAlive
|
||||
}`;
|
||||
|
||||
export const UPDATE_USER_CLIENT_RTT = gql`
|
||||
mutation UpdateConnectionRtt($networkRttInMs: Float!) {
|
||||
userSetConnectionRtt(
|
||||
mutation UpdateConnectionAliveAt($networkRttInMs: Float!) {
|
||||
userSetConnectionAlive(
|
||||
networkRttInMs: $networkRttInMs
|
||||
)
|
||||
}`;
|
||||
|
||||
export default {
|
||||
UPDATE_CONNECTION_ALIVE_AT,
|
||||
UPDATE_USER_CLIENT_RTT,
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CONNECTION_STATUS_REPORT_SUBSCRIPTION = gql`subscription {
|
||||
export const CONNECTION_STATUS_REPORT_SUBSCRIPTION = gql`subscription ConnStatusReport {
|
||||
user_connectionStatusReport {
|
||||
user {
|
||||
userId
|
||||
@ -17,7 +17,7 @@ export const CONNECTION_STATUS_REPORT_SUBSCRIPTION = gql`subscription {
|
||||
}
|
||||
}`;
|
||||
|
||||
export const CONNECTION_STATUS_SUBSCRIPTION = gql`subscription {
|
||||
export const CONNECTION_STATUS_SUBSCRIPTION = gql`subscription ConnStatus {
|
||||
user_connectionStatus {
|
||||
connectionAliveAt
|
||||
userClientResponseAt
|
||||
|
@ -103,7 +103,7 @@ class PushLayoutEngine extends React.Component {
|
||||
|
||||
layoutContextDispatch({
|
||||
type: ACTIONS.SET_CAMERA_DOCK_POSITION,
|
||||
value: meetingLayoutCameraPosition,
|
||||
value: meetingLayoutCameraPosition || 'contentTop',
|
||||
});
|
||||
|
||||
if (!equalDouble(meetingLayoutVideoRate, 0)) {
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
HookEvents,
|
||||
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
|
||||
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
|
||||
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import useLoadedPageGathering from '/imports/ui/core/hooks/useLoadedChatMessages';
|
||||
import { Message } from '/imports/ui/Types/message';
|
||||
import formatLoadedChatMessagesDataFromGraphql from './utils';
|
||||
@ -37,9 +37,9 @@ const LoadedChatMessagesHookContainer = () => {
|
||||
}, [chatMessagesData, sendSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHookUseLoadedChatMessages = () => {
|
||||
setSendSignal(!sendSignal);
|
||||
};
|
||||
const updateHookUseLoadedChatMessages = ((event: CustomEvent<SubscribedEventDetails>) => {
|
||||
if (event.detail.hook === DataConsumptionHooks.LOADED_CHAT_MESSAGES) setSendSignal((signal) => !signal);
|
||||
}) as EventListener;
|
||||
window.addEventListener(
|
||||
HookEvents.SUBSCRIBED, updateHookUseLoadedChatMessages,
|
||||
);
|
||||
|
@ -4,6 +4,7 @@ import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
|
||||
import {
|
||||
HookEvents,
|
||||
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
|
||||
import { SubscribedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
|
||||
|
||||
import { CurrentPresentation } from '/imports/ui/Types/presentation';
|
||||
@ -40,9 +41,9 @@ const CurrentPresentationHookContainer = () => {
|
||||
}, [currentPresentation, sendSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHookUseCurrentPresentation = () => {
|
||||
setSendSignal(!sendSignal);
|
||||
};
|
||||
const updateHookUseCurrentPresentation = ((event: CustomEvent<SubscribedEventDetails>) => {
|
||||
if (event.detail.hook === DataConsumptionHooks.CURRENT_PRESENTATION) setSendSignal((signal) => !signal);
|
||||
}) as EventListener;
|
||||
window.addEventListener(
|
||||
HookEvents.SUBSCRIBED, updateHookUseCurrentPresentation,
|
||||
);
|
||||
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useSubscription, gql } from '@apollo/client';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import { CustomSubscriptionArguments } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/domain/shared/custom-subscription/types';
|
||||
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import {
|
||||
HookEvents,
|
||||
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
|
||||
@ -53,8 +53,8 @@ const CustomSubscriptionHookContainer = (props: HookWithArgumentsContainerProps)
|
||||
}, [customSubscriptionData, sendSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHookUseCustomSubscription = (() => {
|
||||
setSendSignal((previous) => !previous);
|
||||
const updateHookUseCustomSubscription = ((event: CustomEvent<SubscribedEventDetails>) => {
|
||||
if (event.detail.hook === DataConsumptionHooks.CUSTOM_SUBSCRIPTION) setSendSignal((signal) => !signal);
|
||||
}) as EventListener;
|
||||
window.addEventListener(
|
||||
HookEvents.SUBSCRIBED, updateHookUseCustomSubscription,
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
HookEvents,
|
||||
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
|
||||
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
|
||||
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import formatTalkingIndicatorDataFromGraphql from './utils';
|
||||
import { UserVoice } from '/imports/ui/Types/userVoice';
|
||||
import { useTalkingIndicatorList } from '/imports/ui/core/hooks/useTalkingIndicator';
|
||||
@ -36,9 +36,9 @@ const TalkingIndicatorHookContainer = () => {
|
||||
}, [userVoice, sendSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHookUseTalkingIndicator = () => {
|
||||
setSendSignal(!sendSignal);
|
||||
};
|
||||
const updateHookUseTalkingIndicator = ((event: CustomEvent<SubscribedEventDetails>) => {
|
||||
if (event.detail.hook === DataConsumptionHooks.TALKING_INDICATOR) setSendSignal((signal) => !signal);
|
||||
}) as EventListener;
|
||||
window.addEventListener(
|
||||
HookEvents.SUBSCRIBED, updateHookUseTalkingIndicator,
|
||||
);
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
|
||||
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import {
|
||||
HookEvents,
|
||||
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
|
||||
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
|
||||
|
||||
import { equals } from 'ramda';
|
||||
import formatCurrentUserResponseFromGraphql from './utils';
|
||||
import { User } from '/imports/ui/Types/user';
|
||||
import { GeneralHookManagerProps } from '../../../types';
|
||||
@ -17,6 +18,7 @@ const CurrentUserHookContainer: React.FunctionComponent<
|
||||
props: GeneralHookManagerProps<GraphqlDataHookSubscriptionResponse<Partial<User>>>,
|
||||
) => {
|
||||
const [sendSignal, setSendSignal] = useState(false);
|
||||
const previousCurrentUser = useRef<GraphqlDataHookSubscriptionResponse<Partial<User>> | null>(null);
|
||||
|
||||
const { data: currentUser } = props;
|
||||
|
||||
@ -38,13 +40,18 @@ const CurrentUserHookContainer: React.FunctionComponent<
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
updateUserForPlugin();
|
||||
}, [currentUser, sendSignal]);
|
||||
|
||||
if (!equals(previousCurrentUser.current, currentUser)) {
|
||||
previousCurrentUser.current = currentUser;
|
||||
updateUserForPlugin();
|
||||
}
|
||||
}, [currentUser]);
|
||||
useEffect(() => {
|
||||
const updateHookUseCurrentUser = () => {
|
||||
setSendSignal(!sendSignal);
|
||||
};
|
||||
updateUserForPlugin();
|
||||
}, [sendSignal]);
|
||||
useEffect(() => {
|
||||
const updateHookUseCurrentUser = ((event: CustomEvent<SubscribedEventDetails>) => {
|
||||
if (event.detail.hook === DataConsumptionHooks.CURRENT_USER) setSendSignal((signal) => !signal);
|
||||
}) as EventListener;
|
||||
window.addEventListener(
|
||||
HookEvents.SUBSCRIBED, updateHookUseCurrentUser,
|
||||
);
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
HookEvents,
|
||||
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/enum';
|
||||
import { DataConsumptionHooks } from 'bigbluebutton-html-plugin-sdk/dist/cjs/data-consumption/enums';
|
||||
import { UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import { SubscribedEventDetails, UpdatedEventDetails } from 'bigbluebutton-html-plugin-sdk/dist/cjs/core/types';
|
||||
import formatLoadedUserListDataFromGraphql from './utils';
|
||||
import { useLocalUserList } from '/imports/ui/core/hooks/useLoadedUserList';
|
||||
|
||||
@ -33,9 +33,9 @@ const LoadedUserListHookContainer = () => {
|
||||
}, [usersData, sendSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateHookUseLoadedUserList = () => {
|
||||
setSendSignal(!sendSignal);
|
||||
};
|
||||
const updateHookUseLoadedUserList = ((event: CustomEvent<SubscribedEventDetails>) => {
|
||||
if (event.detail.hook === DataConsumptionHooks.LOADED_USER_LIST) setSendSignal((signal) => !signal);
|
||||
}) as EventListener;
|
||||
window.addEventListener(
|
||||
HookEvents.SUBSCRIBED, updateHookUseLoadedUserList,
|
||||
);
|
||||
|
@ -43,7 +43,8 @@ const ActionBarPluginStateContainer = ((
|
||||
|
||||
pluginApi.setActionsBarItems = (items: PluginSdk.ActionsBarInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionsBarInterface[];
|
||||
return setActionBarItems(itemsWithId);
|
||||
setActionBarItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const ActionButtonDropdownPluginStateContainer = ((
|
||||
|
||||
pluginApi.setActionButtonDropdownItems = (items: PluginSdk.ActionButtonDropdownInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.ActionButtonDropdownInterface[];
|
||||
return setActionButtonDropdownItems(itemsWithId);
|
||||
setActionButtonDropdownItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -45,7 +45,8 @@ const AudioSettingsDropdownPluginStateContainer = ((
|
||||
|
||||
pluginApi.setAudioSettingsDropdownItems = (items: PluginSdk.AudioSettingsDropdownInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.AudioSettingsDropdownInterface[];
|
||||
return setAudioSettingsDropdownItems(itemsWithId);
|
||||
setAudioSettingsDropdownItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const CameraSettingsDropdownPluginStateContainer = ((
|
||||
|
||||
pluginApi.setCameraSettingsDropdownItems = (items: PluginSdk.CameraSettingsDropdownInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.CameraSettingsDropdownInterface[];
|
||||
return setCameraSettingsDropdownItems(itemsWithId);
|
||||
setCameraSettingsDropdownItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const FloatingWindowPluginStateContainer = ((
|
||||
|
||||
pluginApi.setFloatingWindows = (items: PluginSdk.FloatingWindowInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.FloatingWindowInterface[];
|
||||
return setFloatingWindows(itemsWithId);
|
||||
setFloatingWindows(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const GenericComponentPluginStateContainer = ((
|
||||
|
||||
pluginApi.setGenericComponents = (items: PluginSdk.GenericComponentInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.GenericComponentInterface[];
|
||||
return setGenericComponents(itemsWithId);
|
||||
setGenericComponents(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const NavBarPluginStateContainer = ((
|
||||
|
||||
pluginApi.setNavBarItems = (items: PluginSdk.NavBarInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.NavBarInterface[];
|
||||
return setNavBarItems(itemsWithId);
|
||||
setNavBarItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const OptionsDropdownPluginStateContainer = ((
|
||||
|
||||
pluginApi.setOptionsDropdownItems = (items: PluginSdk.OptionsDropdownInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.OptionsDropdownInterface[];
|
||||
return setOptionsDropdownItems(itemsWithId);
|
||||
setOptionsDropdownItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const PresentationDropdownPluginStateContainer = ((
|
||||
|
||||
pluginApi.setPresentationDropdownItems = (items: PluginSdk.PresentationDropdownInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationDropdownInterface[];
|
||||
return setPresentationDropdownItems(itemsWithId);
|
||||
setPresentationDropdownItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const PresentationToolbarPluginStateContainer = ((
|
||||
|
||||
pluginApi.setPresentationToolbarItems = (items: PluginSdk.PresentationToolbarInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.PresentationToolbarInterface[];
|
||||
return setPresentationToolbarItems(itemsWithId);
|
||||
setPresentationToolbarItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
@ -46,7 +46,8 @@ const UserCameraDropdownPluginStateContainer = ((
|
||||
|
||||
pluginApi.setUserCameraDropdownItems = (items: PluginSdk.UserCameraDropdownInterface[]) => {
|
||||
const itemsWithId = items.map(generateItemWithId) as PluginSdk.UserCameraDropdownInterface[];
|
||||
return setUserCameraDropdownItems(itemsWithId);
|
||||
setUserCameraDropdownItems(itemsWithId);
|
||||
return itemsWithId.map((i) => i.id);
|
||||
};
|
||||
return null;
|
||||
}) as ExtensibleAreaComponentManager;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user